Show page loading progress

This commit is contained in:
Koitharu
2022-03-06 15:14:28 +02:00
parent 9588ac8cbd
commit 889eea9c89
15 changed files with 186 additions and 137 deletions

View File

@@ -10,13 +10,13 @@ import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob
fun downloadItemAD(
scope: CoroutineScope,
coil: ImageLoader,
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
) = adapterDelegateViewBinding<ProgressJob<DownloadManager.State>, ProgressJob<DownloadManager.State>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) {
@@ -24,7 +24,7 @@ fun downloadItemAD(
bind {
job?.cancel()
job = item.onFirst { state ->
job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(state.manga.coverUrl)
.referer(state.manga.publicUrl)
.placeholder(state.cover)

View File

@@ -5,12 +5,12 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.progress.ProgressJob
class DownloadsAdapter(
scope: CoroutineScope,
coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadManager.State>>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(scope, coil))
@@ -18,23 +18,23 @@ class DownloadsAdapter(
}
override fun getItemId(position: Int): Long {
return items[position].value.startId.toLong()
return items[position].progressValue.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<JobStateFlow<DownloadManager.State>>() {
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadManager.State>>() {
override fun areItemsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
oldItem: ProgressJob<DownloadManager.State>,
newItem: ProgressJob<DownloadManager.State>,
): Boolean {
return oldItem.value.startId == newItem.value.startId
return oldItem.progressValue.startId == newItem.progressValue.startId
}
override fun areContentsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
oldItem: ProgressJob<DownloadManager.State>,
newItem: ProgressJob<DownloadManager.State>,
): Boolean {
return oldItem.value == newItem.value
return oldItem.progressValue == newItem.progressValue
}
}
}

View File

@@ -31,8 +31,8 @@ import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.toArraySet
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.util.concurrent.TimeUnit
import kotlin.collections.set
@@ -42,7 +42,7 @@ class DownloadService : BaseService() {
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var downloadManager: DownloadManager
private val jobs = LinkedHashMap<Int, JobStateFlow<DownloadManager.State>>()
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadManager.State>>()
private val jobCount = MutableStateFlow(0)
private val mutex = Mutex()
private val controlReceiver = ControlReceiver()
@@ -93,7 +93,7 @@ class DownloadService : BaseService() {
startId: Int,
manga: Manga,
chaptersIds: Set<Long>?,
): JobStateFlow<DownloadManager.State> {
): ProgressJob<DownloadManager.State> {
val initialState = DownloadManager.State.Queued(startId, manga, null)
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
val job = lifecycleScope.launch {
@@ -131,7 +131,7 @@ class DownloadService : BaseService() {
}
}
}
return JobStateFlow(stateFlow, job)
return ProgressJob(job, stateFlow)
}
inner class ControlReceiver : BroadcastReceiver() {
@@ -149,7 +149,7 @@ class DownloadService : BaseService() {
class DownloadBinder(private val service: DownloadService) : Binder() {
val downloads: Flow<Collection<JobStateFlow<DownloadManager.State>>>
val downloads: Flow<Collection<ProgressJob<DownloadManager.State>>>
get() = service.jobCount.mapLatest { service.jobs.values }
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data
import android.content.Context
import com.tomclaw.cache.DiskLruCache
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir
@@ -30,4 +31,33 @@ class PagesCache(context: Context) {
file.delete()
return res
}
fun put(
url: String,
inputStream: InputStream,
contentLength: Long,
progress: MutableStateFlow<Float>,
): File {
val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use { out ->
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
publishProgress(contentLength, bytesCopied, progress)
bytes = inputStream.read(buffer)
}
}
val res = lruCache.put(url, file)
file.delete()
return res
}
private fun publishProgress(contentLength: Long, bytesCopied: Long, progress: MutableStateFlow<Float>) {
if (contentLength > 0) {
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
}
}
}

View File

@@ -6,6 +6,8 @@ import android.net.Uri
import androidx.collection.LongSparseArray
import androidx.collection.set
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
@@ -22,23 +24,28 @@ 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 org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10
class PageLoader : KoinComponent, Closeable {
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<ProgressDeferred<File, Float>>()
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
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
private val emptyProgressFlow: StateFlow<Float> = MutableStateFlow(-1f)
override fun close() {
loaderScope.cancel()
@@ -66,21 +73,25 @@ class PageLoader : KoinComponent, Closeable {
}
}
suspend fun loadPage(page: MangaPage, force: Boolean): File {
fun loadPageAsync(page: MangaPage, force: Boolean) : ProgressDeferred<File, Float> {
if (!force) {
cache[page.url]?.let {
return it
return getCompletedTask(it)
}
}
var task = tasks[page.id]
if (force) {
task?.cancel()
} else if (task?.isCancelled == false) {
return task.await()
return task
}
task = loadPageAsync(page)
task = loadPageAsyncImpl(page)
tasks[page.id] = task
return task.await()
return task
}
suspend fun loadPage(page: MangaPage, force: Boolean): File {
return loadPageAsync(page, force).await()
}
suspend fun convertInPlace(file: File) {
@@ -101,21 +112,23 @@ class PageLoader : KoinComponent, Closeable {
private fun onIdle() {
synchronized(prefetchQueue) {
val page = prefetchQueue.pollFirst() ?: return
tasks[page.id] = loadPageAsync(page)
tasks[page.id] = loadPageAsyncImpl(page)
}
}
private fun loadPageAsync(page: MangaPage): Deferred<File> {
return loaderScope.async {
private fun loadPageAsyncImpl(page: MangaPage): ProgressDeferred<File, Float> {
val progress = MutableStateFlow(PROGRESS_UNDEFINED)
val deferred = loaderScope.async {
counter.incrementAndGet()
try {
loadPageImpl(page)
loadPageImpl(page, progress)
} finally {
if (counter.decrementAndGet() == 0) {
onIdle()
}
}
}
return ProgressDeferred(deferred, progress)
}
@Synchronized
@@ -128,7 +141,7 @@ class PageLoader : KoinComponent, Closeable {
}
}
private suspend fun loadPageImpl(page: MangaPage): File {
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File {
val pageUrl = getRepository(page.source).getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl)
@@ -157,10 +170,15 @@ class PageLoader : KoinComponent, Closeable {
}
runInterruptible(Dispatchers.IO) {
body.byteStream().use {
cache.put(pageUrl, it)
cache.put(pageUrl, it, body.contentLength(), progress)
}
}
}
}
}
private fun getCompletedTask(file: File): ProgressDeferred<File, Float> {
val deferred = CompletableDeferred(file)
return ProgressDeferred(deferred, emptyProgressFlow)
}
}

View File

@@ -3,18 +3,17 @@ 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.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.plus
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.launchAfter
import org.koitharu.kotatsu.utils.ext.launchInstead
import java.io.File
import java.io.IOException
@@ -32,13 +31,17 @@ class PageHolderDelegate(
private var error: Throwable? = null
fun onBind(page: MangaPage) {
job = scope.launchInstead(job) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
doLoad(page, force = false)
}
}
fun retry(page: MangaPage) {
job = scope.launchInstead(job) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
(error as? ResolvableException)?.let {
exceptionResolver.resolve(it)
}
@@ -69,30 +72,39 @@ class PageHolderDelegate(
val file = this.file
error = e
if (state == State.LOADED && e is IOException && file != null && file.exists()) {
job = scope.launchAfter(job) {
state = State.CONVERTING
try {
loader.convertInPlace(file)
state = State.CONVERTED
callback.onImageReady(file.toUri())
} catch (e2: Throwable) {
e.addSuppressed(e2)
state = State.ERROR
callback.onError(e)
}
}
tryConvert(file, e)
} else {
state = State.ERROR
callback.onError(e)
}
}
private suspend fun doLoad(data: MangaPage, force: Boolean) {
private fun tryConvert(file: File, e: Exception) {
val prevJob = job
job = scope.launch {
prevJob?.join()
state = State.CONVERTING
try {
loader.convertInPlace(file)
state = State.CONVERTED
callback.onImageReady(file.toUri())
} catch (e2: Throwable) {
e.addSuppressed(e2)
state = State.ERROR
callback.onError(e)
}
}
}
private suspend fun CoroutineScope.doLoad(data: MangaPage, force: Boolean) {
state = State.LOADING
error = null
callback.onLoadingStarted()
try {
val file = loader.loadPage(data, force)
val task = loader.loadPageAsync(data, force)
val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await()
progressObserver.cancel()
this@PageHolderDelegate.file = file
state = State.LOADED
callback.onImageReady(file.toUri())
@@ -105,6 +117,11 @@ class PageHolderDelegate(
}
}
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.debounce(500)
.onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope)
private enum class State {
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
}
@@ -120,5 +137,7 @@ class PageHolderDelegate(
fun onImageShowing(zoom: ZoomMode)
fun onImageShown()
fun onProgressChanged(progress: Int)
}
}

View File

@@ -44,9 +44,14 @@ open class PageHolder(
override fun onLoadingStarted() {
binding.layoutError.isVisible = false
binding.progressBar.isVisible = true
binding.textViewProgress.isVisible = true
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
binding.textViewProgress.text = if (progress in 0..100) "%d%%".format(progress) else null
}
override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.uri(uri))
}
@@ -89,6 +94,7 @@ open class PageHolder(
override fun onImageShown() {
binding.progressBar.isVisible = false
binding.textViewProgress.isVisible = false
}
override fun onClick(v: View) {
@@ -104,5 +110,6 @@ open class PageHolder(
)
binding.layoutError.isVisible = true
binding.progressBar.isVisible = false
binding.textViewProgress.isVisible = false
}
}

View File

@@ -44,9 +44,14 @@ class WebtoonHolder(
override fun onLoadingStarted() {
binding.layoutError.isVisible = false
binding.progressBar.isVisible = true
binding.textViewProgress.isVisible = true
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
binding.textViewProgress.text = if (progress in 0..100) "%d%%".format(progress) else null
}
override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.uri(uri))
}
@@ -68,6 +73,7 @@ class WebtoonHolder(
override fun onImageShown() {
binding.progressBar.isVisible = false
binding.textViewProgress.isVisible = false
}
override fun onClick(v: View) {
@@ -83,6 +89,7 @@ class WebtoonHolder(
)
binding.layoutError.isVisible = true
binding.progressBar.isVisible = false
binding.textViewProgress.isVisible = false
}
fun getScrollY() = binding.ssiv.getScroll()

View File

@@ -1,22 +0,0 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
class DeferredStateFlow<R, S>(
private val stateFlow: StateFlow<S>,
private val deferred: Deferred<R>,
) : StateFlow<S> by stateFlow, Deferred<R> by deferred {
suspend fun collectAndAwait(): R {
return coroutineScope {
val collectJob = launchIn(this)
val result = await()
collectJob.cancelAndJoin()
result
}
}
}

View File

@@ -1,21 +0,0 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
class JobStateFlow<S>(
private val stateFlow: StateFlow<S>,
private val job: Job,
) : StateFlow<S> by stateFlow, Job by job {
suspend fun collectAndJoin(): Unit {
coroutineScope {
val collectJob = launchIn(this)
join()
collectJob.cancelAndJoin()
}
}
}

View File

@@ -4,47 +4,9 @@ import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import java.io.IOException
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
inline fun CoroutineScope.launchAfter(
job: Job?,
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
crossinline block: suspend CoroutineScope.() -> Unit
): Job = launch(context, start) {
try {
job?.join()
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
}
block()
}
inline fun CoroutineScope.launchInstead(
job: Job?,
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
crossinline block: suspend CoroutineScope.() -> Unit
): Job = launch(context, start) {
try {
job?.cancelAndJoin()
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
}
block()
}
val IgnoreErrors
get() = CoroutineExceptionHandler { _, e ->

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.utils.progress
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
class ProgressDeferred<T, P>(
private val deferred: Deferred<T>,
private val progress: StateFlow<P>,
) : Deferred<T> by deferred {
val progressValue: P
get() = progress.value
fun progressAsFlow(): Flow<P> = progress
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.utils.progress
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
class ProgressJob<P>(
private val job: Job,
private val progress: StateFlow<P>,
) : Job by job {
val progressValue: P
get() = progress.value
fun progressAsFlow(): Flow<P> = progress
}

View File

@@ -19,6 +19,15 @@
android:layout_height="wrap_content"
android:layout_gravity="center" />
<TextView
android:id="@+id/textView_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
tools:text="146%" />
<TextView
android:id="@+id/textView_number"
android:layout_width="wrap_content"
@@ -39,8 +48,7 @@
android:layout_marginEnd="60dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
android:visibility="gone">
<TextView
android:id="@+id/textView_error"
@@ -52,7 +60,7 @@
app:drawableTopCompat="@drawable/ic_error_large"
tools:text="@tools:sample/lorem[6]" />
<com.google.android.material.button.MaterialButton
<Button
android:id="@+id/button_retry"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"

View File

@@ -21,6 +21,15 @@
android:layout_height="wrap_content"
android:layout_gravity="center" />
<TextView
android:id="@+id/textView_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
tools:text="146%" />
<LinearLayout
android:id="@+id/layout_error"
android:layout_width="wrap_content"