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

View File

@@ -5,12 +5,12 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow import org.koitharu.kotatsu.utils.progress.ProgressJob
class DownloadsAdapter( class DownloadsAdapter(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader, coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadManager.State>>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(downloadItemAD(scope, coil)) delegatesManager.addDelegate(downloadItemAD(scope, coil))
@@ -18,23 +18,23 @@ class DownloadsAdapter(
} }
override fun getItemId(position: Int): Long { 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( override fun areItemsTheSame(
oldItem: JobStateFlow<DownloadManager.State>, oldItem: ProgressJob<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>, newItem: ProgressJob<DownloadManager.State>,
): Boolean { ): Boolean {
return oldItem.value.startId == newItem.value.startId return oldItem.progressValue.startId == newItem.progressValue.startId
} }
override fun areContentsTheSame( override fun areContentsTheSame(
oldItem: JobStateFlow<DownloadManager.State>, oldItem: ProgressJob<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>, newItem: ProgressJob<DownloadManager.State>,
): Boolean { ): 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.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager 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.ext.toArraySet
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.set import kotlin.collections.set
@@ -42,7 +42,7 @@ class DownloadService : BaseService() {
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var downloadManager: DownloadManager 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 jobCount = MutableStateFlow(0)
private val mutex = Mutex() private val mutex = Mutex()
private val controlReceiver = ControlReceiver() private val controlReceiver = ControlReceiver()
@@ -93,7 +93,7 @@ class DownloadService : BaseService() {
startId: Int, startId: Int,
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: Set<Long>?,
): JobStateFlow<DownloadManager.State> { ): ProgressJob<DownloadManager.State> {
val initialState = DownloadManager.State.Queued(startId, manga, null) val initialState = DownloadManager.State.Queued(startId, manga, null)
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState) val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
val job = lifecycleScope.launch { val job = lifecycleScope.launch {
@@ -131,7 +131,7 @@ class DownloadService : BaseService() {
} }
} }
} }
return JobStateFlow(stateFlow, job) return ProgressJob(job, stateFlow)
} }
inner class ControlReceiver : BroadcastReceiver() { inner class ControlReceiver : BroadcastReceiver() {
@@ -149,7 +149,7 @@ class DownloadService : BaseService() {
class DownloadBinder(private val service: DownloadService) : Binder() { 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 } get() = service.jobCount.mapLatest { service.jobs.values }
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data
import android.content.Context import android.content.Context
import com.tomclaw.cache.DiskLruCache import com.tomclaw.cache.DiskLruCache
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.subdir
@@ -30,4 +31,33 @@ class PagesCache(context: Context) {
file.delete() file.delete()
return res 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.LongSparseArray
import androidx.collection.set import androidx.collection.set
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient 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.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 org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile import java.util.zip.ZipFile
private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10
class PageLoader : KoinComponent, Closeable { class PageLoader : KoinComponent, Closeable {
val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val okHttp = get<OkHttpClient>() private val okHttp = get<OkHttpClient>()
private val cache = get<PagesCache>() private val cache = get<PagesCache>()
private val tasks = LongSparseArray<Deferred<File>>() private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
private val convertLock = Mutex() private val convertLock = Mutex()
private var repository: MangaRepository? = null private var repository: MangaRepository? = null
private var prefetchQueue = LinkedList<MangaPage>() private var prefetchQueue = LinkedList<MangaPage>()
private val counter = AtomicInteger(0) 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() { override fun close() {
loaderScope.cancel() 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) { if (!force) {
cache[page.url]?.let { cache[page.url]?.let {
return it return getCompletedTask(it)
} }
} }
var task = tasks[page.id] var task = tasks[page.id]
if (force) { if (force) {
task?.cancel() task?.cancel()
} else if (task?.isCancelled == false) { } else if (task?.isCancelled == false) {
return task.await() return task
} }
task = loadPageAsync(page) task = loadPageAsyncImpl(page)
tasks[page.id] = task 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) { suspend fun convertInPlace(file: File) {
@@ -101,21 +112,23 @@ class PageLoader : KoinComponent, Closeable {
private fun onIdle() { private fun onIdle() {
synchronized(prefetchQueue) { synchronized(prefetchQueue) {
val page = prefetchQueue.pollFirst() ?: return val page = prefetchQueue.pollFirst() ?: return
tasks[page.id] = loadPageAsync(page) tasks[page.id] = loadPageAsyncImpl(page)
} }
} }
private fun loadPageAsync(page: MangaPage): Deferred<File> { private fun loadPageAsyncImpl(page: MangaPage): ProgressDeferred<File, Float> {
return loaderScope.async { val progress = MutableStateFlow(PROGRESS_UNDEFINED)
val deferred = loaderScope.async {
counter.incrementAndGet() counter.incrementAndGet()
try { try {
loadPageImpl(page) loadPageImpl(page, progress)
} finally { } finally {
if (counter.decrementAndGet() == 0) { if (counter.decrementAndGet() == 0) {
onIdle() onIdle()
} }
} }
} }
return ProgressDeferred(deferred, progress)
} }
@Synchronized @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) val pageUrl = getRepository(page.source).getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl) val uri = Uri.parse(pageUrl)
@@ -157,10 +170,15 @@ class PageLoader : KoinComponent, Closeable {
} }
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
body.byteStream().use { 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 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.CancellationException import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.Job import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.plus 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.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
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.PageLoader 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.File
import java.io.IOException import java.io.IOException
@@ -32,13 +31,17 @@ class PageHolderDelegate(
private var error: Throwable? = null private var error: Throwable? = null
fun onBind(page: MangaPage) { fun onBind(page: MangaPage) {
job = scope.launchInstead(job) { val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
doLoad(page, force = false) doLoad(page, force = false)
} }
} }
fun retry(page: MangaPage) { fun retry(page: MangaPage) {
job = scope.launchInstead(job) { val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
(error as? ResolvableException)?.let { (error as? ResolvableException)?.let {
exceptionResolver.resolve(it) exceptionResolver.resolve(it)
} }
@@ -69,30 +72,39 @@ 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 = scope.launchAfter(job) { tryConvert(file, e)
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)
}
}
} else { } else {
state = State.ERROR state = State.ERROR
callback.onError(e) 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 state = State.LOADING
error = null error = null
callback.onLoadingStarted() callback.onLoadingStarted()
try { 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 this@PageHolderDelegate.file = file
state = State.LOADED state = State.LOADED
callback.onImageReady(file.toUri()) 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 { private enum class State {
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
} }
@@ -120,5 +137,7 @@ class PageHolderDelegate(
fun onImageShowing(zoom: ZoomMode) fun onImageShowing(zoom: ZoomMode)
fun onImageShown() fun onImageShown()
fun onProgressChanged(progress: Int)
} }
} }

View File

@@ -44,9 +44,14 @@ open class PageHolder(
override fun onLoadingStarted() { override fun onLoadingStarted() {
binding.layoutError.isVisible = false binding.layoutError.isVisible = false
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.textViewProgress.isVisible = true
binding.ssiv.recycle() 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) { override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.uri(uri)) binding.ssiv.setImage(ImageSource.uri(uri))
} }
@@ -89,6 +94,7 @@ open class PageHolder(
override fun onImageShown() { override fun onImageShown() {
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.textViewProgress.isVisible = false
} }
override fun onClick(v: View) { override fun onClick(v: View) {
@@ -104,5 +110,6 @@ open class PageHolder(
) )
binding.layoutError.isVisible = true binding.layoutError.isVisible = true
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.textViewProgress.isVisible = false
} }
} }

View File

@@ -44,9 +44,14 @@ class WebtoonHolder(
override fun onLoadingStarted() { override fun onLoadingStarted() {
binding.layoutError.isVisible = false binding.layoutError.isVisible = false
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.textViewProgress.isVisible = true
binding.ssiv.recycle() 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) { override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.uri(uri)) binding.ssiv.setImage(ImageSource.uri(uri))
} }
@@ -68,6 +73,7 @@ class WebtoonHolder(
override fun onImageShown() { override fun onImageShown() {
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.textViewProgress.isVisible = false
} }
override fun onClick(v: View) { override fun onClick(v: View) {
@@ -83,6 +89,7 @@ class WebtoonHolder(
) )
binding.layoutError.isVisible = true binding.layoutError.isVisible = true
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.textViewProgress.isVisible = false
} }
fun getScrollY() = binding.ssiv.getScroll() 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.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.io.IOException
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext 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 val IgnoreErrors
get() = CoroutineExceptionHandler { _, e -> 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_height="wrap_content"
android:layout_gravity="center" /> 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 <TextView
android:id="@+id/textView_number" android:id="@+id/textView_number"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -39,8 +48,7 @@
android:layout_marginEnd="60dp" android:layout_marginEnd="60dp"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone" android:visibility="gone">
tools:visibility="visible">
<TextView <TextView
android:id="@+id/textView_error" android:id="@+id/textView_error"
@@ -52,7 +60,7 @@
app:drawableTopCompat="@drawable/ic_error_large" app:drawableTopCompat="@drawable/ic_error_large"
tools:text="@tools:sample/lorem[6]" /> tools:text="@tools:sample/lorem[6]" />
<com.google.android.material.button.MaterialButton <Button
android:id="@+id/button_retry" android:id="@+id/button_retry"
style="@style/Widget.Material3.Button.TonalButton" style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -21,6 +21,15 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" /> 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 <LinearLayout
android:id="@+id/layout_error" android:id="@+id/layout_error"
android:layout_width="wrap_content" android:layout_width="wrap_content"