Merge branch 'feature/page-preload' into devel

This commit is contained in:
Koitharu
2022-03-08 19:00:13 +02:00
28 changed files with 380 additions and 199 deletions

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
@@ -147,6 +148,14 @@ class AppSettings(context: Context) {
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true
NETWORK_NEVER -> false
else -> cm.isActiveNetworkMetered
}
}
fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
@@ -237,6 +246,7 @@ class AppSettings(context: Context) {
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers"
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
@@ -250,6 +260,10 @@ class AppSettings(context: Context) {
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1
private const val NETWORK_NON_METERED = 2
val isDynamicColorAvailable: Boolean
get() = DynamicColors.isDynamicColorAvailable() ||
(isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)

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

@@ -4,7 +4,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
@@ -31,8 +30,9 @@ 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.connectivityManager
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 }
}
@@ -183,9 +183,8 @@ class DownloadService : BaseService() {
.putExtra(ACTION_DOWNLOAD_CANCEL, startId)
private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val settings = GlobalContext.get().get<AppSettings>()
if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
CheckBoxAlertDialog.Builder(context)
.setTitle(R.string.warning)
.setMessage(R.string.network_consumption_warning)

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

@@ -1,94 +1,107 @@
package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
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
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.core.prefs.AppSettings
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.connectivityManager
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
class PageLoader(
scope: CoroutineScope,
private val okHttp: OkHttpClient,
private val cache: PagesCache
) : CoroutineScope by scope, KoinComponent {
private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10
private var repository: MangaRepository? = null
private val tasks = LongSparseArray<Deferred<File>>()
class PageLoader : KoinComponent, Closeable {
val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val okHttp = get<OkHttpClient>()
private val cache = get<PagesCache>()
private val settings = get<AppSettings>()
private val connectivityManager = get<Context>().connectivityManager
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 = PREFETCH_LIMIT_DEFAULT // TODO adaptive
private val emptyProgressFlow: StateFlow<Float> = MutableStateFlow(-1f)
suspend fun loadPage(page: MangaPage, force: Boolean): File {
override fun close() {
loaderScope.cancel()
tasks.clear()
}
fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository && settings.isPagesPreloadAllowed(connectivityManager)
}
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()
}
}
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 = loadAsync(page)
task = loadPageAsyncImpl(page)
tasks[page.id] = task
return task.await()
return task
}
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 loadPage(page: MangaPage, force: Boolean): File {
return loadPageAsync(page, force).await()
}
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 +114,76 @@ class PageLoader(
}
}
private companion object Lock
}
private fun onIdle() {
synchronized(prefetchQueue) {
val page = prefetchQueue.pollFirst() ?: return
tasks[page.id] = loadPageAsyncImpl(page)
}
}
private fun loadPageAsyncImpl(page: MangaPage): ProgressDeferred<File, Float> {
val progress = MutableStateFlow(PROGRESS_UNDEFINED)
val deferred = loaderScope.async {
counter.incrementAndGet()
try {
loadPageImpl(page, progress)
} finally {
if (counter.decrementAndGet() == 0) {
onIdle()
}
}
}
return ProgressDeferred(deferred, progress)
}
@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, progress: MutableStateFlow<Float>): 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, body.contentLength(), progress)
}
}
}
}
}
private fun getCompletedTask(file: File): ProgressDeferred<File, Float> {
val deferred = CompletableDeferred(file)
return ProgressDeferred(deferred, emptyProgressFlow)
}
}

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.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) {

View File

@@ -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()
}
}

View File

@@ -3,12 +3,9 @@ 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
@@ -17,9 +14,6 @@ private const val KEY_STATE = "state"
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?) {

View File

@@ -4,14 +4,16 @@ import android.net.Uri
import androidx.core.net.toUri
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
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
@@ -20,21 +22,26 @@ 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) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
doLoad(page, force = false)
}
}
fun retry(page: MangaPage) {
job = launchInstead(job) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
(error as? ResolvableException)?.let {
exceptionResolver.resolve(it)
}
@@ -65,30 +72,39 @@ class PageHolderDelegate(
val file = this.file
error = e
if (state == State.LOADED && e is IOException && file != null && file.exists()) {
job = 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())
@@ -101,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
}
@@ -116,5 +137,7 @@ class PageHolderDelegate(
fun onImageShowing(zoom: ZoomMode)
fun onImageShown()
fun onProgressChanged(progress: Int)
}
}

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.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

View File

@@ -37,7 +37,7 @@ open class PageHolder(
}
override fun onRecycled() {
delegate.onRecycle()
super.onRecycled()
binding.ssiv.recycle()
}
@@ -47,6 +47,15 @@ open class PageHolder(
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
binding.progressBar.isIndeterminate = false
binding.progressBar.setProgressCompat(progress, true)
} else {
binding.progressBar.isIndeterminate = true
}
}
override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.uri(uri))
}

View File

@@ -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

View File

@@ -37,7 +37,7 @@ class WebtoonHolder(
}
override fun onRecycled() {
delegate.onRecycle()
super.onRecycled()
binding.ssiv.recycle()
}
@@ -47,6 +47,15 @@ class WebtoonHolder(
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
binding.progressBar.isIndeterminate = false
binding.progressBar.setProgressCompat(progress, true)
} else {
binding.progressBar.isIndeterminate = true
}
}
override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.uri(uri))
}

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.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

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,11 +4,16 @@ 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
import kotlin.coroutines.resume
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
suspend fun ConnectivityManager.waitForNetwork(): Network {
val request = NetworkRequest.Builder().build()
return suspendCancellableCoroutine<Network> { cont ->
@@ -26,4 +31,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"
}
}

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

@@ -166,6 +166,7 @@ inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
}
@Deprecated("Useless")
fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) {
if (isIndeterminate != indeterminate) {
if (indeterminate && visibility == View.VISIBLE) {

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

@@ -14,20 +14,21 @@
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:indeterminate="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
android:layout_gravity="center"
android:indeterminate="true"
android:max="100" />
<TextView
android:id="@+id/textView_number"
android:layout_width="wrap_content"
android:layout_margin="8dp"
android:singleLine="true"
android:textColor="?android:textColorTertiary"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_margin="8dp"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:textColorTertiary"
tools:text="5" />
<LinearLayout
@@ -39,8 +40,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 +52,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

@@ -16,10 +16,11 @@
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:indeterminate="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
android:layout_gravity="center"
android:indeterminate="true"
android:max="100" />
<LinearLayout
android:id="@+id/layout_error"

View File

@@ -29,4 +29,9 @@
<item>@string/screenshots_block_nsfw</item>
<item>@string/screenshots_block_all</item>
</string-array>
<string-array name="network_policy">
<item>@string/always</item>
<item>@string/only_using_wifi</item>
<item>@string/never</item>
</string-array>
</resources>

View File

@@ -24,4 +24,9 @@
<item>block_nsfw</item>
<item>block_all</item>
</string-array>
<string-array name="values_network_policy">
<item>1</item>
<item>2</item>
<item>0</item>
</string-array>
</resources>

View File

@@ -261,4 +261,8 @@
<string name="reset_filter">Reset filter</string>
<string name="find_genre">Find genre</string>
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
<string name="never">Never</string>
<string name="only_using_wifi">Only using WiFi</string>
<string name="always">Always</string>
<string name="preload_pages">Preload pages</string>
</resources>

View File

@@ -47,4 +47,13 @@
app:iconSpaceReserved="false"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="pages_preload"
android:title="@string/preload_pages"
app:defaultValue="2"
app:iconSpaceReserved="false"
app:useSimpleSummaryProvider="true" />
</PreferenceScreen>