diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt index 20724d769..e854a23b8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt @@ -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, ItemDownloadBinding>( +) = adapterDelegateViewBinding, ProgressJob, 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) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt index 325180a79..37df8b9d8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt @@ -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>(DiffCallback()) { +) : AsyncListDifferDelegationAdapter>(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>() { + private class DiffCallback : DiffUtil.ItemCallback>() { override fun areItemsTheSame( - oldItem: JobStateFlow, - newItem: JobStateFlow, + oldItem: ProgressJob, + newItem: ProgressJob, ): Boolean { - return oldItem.value.startId == newItem.value.startId + return oldItem.progressValue.startId == newItem.progressValue.startId } override fun areContentsTheSame( - oldItem: JobStateFlow, - newItem: JobStateFlow, + oldItem: ProgressJob, + newItem: ProgressJob, ): Boolean { - return oldItem.value == newItem.value + return oldItem.progressValue == newItem.progressValue } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index f5681df51..e7fb68375 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -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>() + private val jobs = LinkedHashMap>() 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?, - ): JobStateFlow { + ): ProgressJob { val initialState = DownloadManager.State.Queued(startId, manga, null) val stateFlow = MutableStateFlow(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>> + val downloads: Flow>> get() = service.jobCount.mapLatest { service.jobs.values } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt index ceed3cd24..dd0782493 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -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, + ): 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) { + if (contentLength > 0) { + progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat() + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 0e67c1da6..965d9bcfc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -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() private val cache = get() - private val tasks = LongSparseArray>() + private val tasks = LongSparseArray>() private val convertLock = Mutex() private var repository: MangaRepository? = null private var prefetchQueue = LinkedList() private val counter = AtomicInteger(0) - private var prefetchQueueLimit = 10 // TODO adaptive + private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive + private val emptyProgressFlow: StateFlow = 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 { 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 { - return loaderScope.async { + private fun loadPageAsyncImpl(page: MangaPage): ProgressDeferred { + 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): 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 { + val deferred = CompletableDeferred(file) + return ProgressDeferred(deferred, emptyProgressFlow) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index 6b7813298..041bc38da 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -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) = 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) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index 3a5adbb0d..4ac9c7e7a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index 466791aa3..642f24212 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -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() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt deleted file mode 100644 index 5cb69e049..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt +++ /dev/null @@ -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( - private val stateFlow: StateFlow, - private val deferred: Deferred, -) : StateFlow by stateFlow, Deferred by deferred { - - suspend fun collectAndAwait(): R { - return coroutineScope { - val collectJob = launchIn(this) - val result = await() - collectJob.cancelAndJoin() - result - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt deleted file mode 100644 index 05af51f10..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt +++ /dev/null @@ -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( - private val stateFlow: StateFlow, - private val job: Job, -) : StateFlow by stateFlow, Job by job { - - suspend fun collectAndJoin(): Unit { - coroutineScope { - val collectJob = launchIn(this) - join() - collectJob.cancelAndJoin() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt index f98427092..40f1268b9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt @@ -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 -> diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt new file mode 100644 index 000000000..7fd1a9357 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt @@ -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( + private val deferred: Deferred, + private val progress: StateFlow

, +) : Deferred by deferred { + + val progressValue: P + get() = progress.value + + fun progressAsFlow(): Flow

= progress +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt new file mode 100644 index 000000000..d401fc83a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt @@ -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

( + private val job: Job, + private val progress: StateFlow

, +) : Job by job { + + val progressValue: P + get() = progress.value + + fun progressAsFlow(): Flow

= progress +} \ No newline at end of file diff --git a/app/src/main/res/layout/item_page.xml b/app/src/main/res/layout/item_page.xml index 2ce0dffbe..d8cb38ef0 100644 --- a/app/src/main/res/layout/item_page.xml +++ b/app/src/main/res/layout/item_page.xml @@ -19,6 +19,15 @@ android:layout_height="wrap_content" android:layout_gravity="center" /> + + + android:visibility="gone"> - + +