Show chapters in downloads list
This commit is contained in:
@@ -53,7 +53,7 @@ data class DownloadState(
|
||||
private const val DATA_PROGRESS = "progress"
|
||||
private const val DATA_CHAPTERS = "chapter_cnt"
|
||||
private const val DATA_ETA = "eta"
|
||||
private const val DATA_TIMESTAMP = "timestamp"
|
||||
const val DATA_TIMESTAMP = "timestamp"
|
||||
private const val DATA_ERROR = "error"
|
||||
private const val DATA_INDETERMINATE = "indeterminate"
|
||||
private const val DATA_PAUSED = "paused"
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
package org.koitharu.kotatsu.download.ui.list
|
||||
|
||||
import android.transition.TransitionManager
|
||||
import android.view.View
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.work.WorkInfo
|
||||
import coil.ImageLoader
|
||||
import coil.request.SuccessResult
|
||||
import coil.util.CoilUtils
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
||||
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
|
||||
import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
|
||||
@@ -30,7 +38,7 @@ fun downloadItemAD(
|
||||
) {
|
||||
|
||||
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
|
||||
// val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
|
||||
var chaptersJob: Job? = null
|
||||
|
||||
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
|
||||
override fun onClick(v: View) {
|
||||
@@ -38,6 +46,7 @@ fun downloadItemAD(
|
||||
R.id.button_cancel -> listener.onCancelClick(item)
|
||||
R.id.button_resume -> listener.onResumeClick(item)
|
||||
R.id.button_pause -> listener.onPauseClick(item)
|
||||
R.id.imageView_expand -> listener.onExpandClick(item)
|
||||
else -> listener.onItemClick(item, v)
|
||||
}
|
||||
}
|
||||
@@ -46,31 +55,60 @@ fun downloadItemAD(
|
||||
return listener.onItemLongClick(item, v)
|
||||
}
|
||||
}
|
||||
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
|
||||
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
|
||||
|
||||
binding.recyclerViewChapters.adapter = chaptersAdapter
|
||||
binding.buttonCancel.setOnClickListener(clickListener)
|
||||
binding.buttonPause.setOnClickListener(clickListener)
|
||||
binding.buttonResume.setOnClickListener(clickListener)
|
||||
binding.imageViewExpand.setOnClickListener(clickListener)
|
||||
itemView.setOnClickListener(clickListener)
|
||||
itemView.setOnLongClickListener(clickListener)
|
||||
|
||||
bind { payloads ->
|
||||
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
|
||||
TransitionManager.beginDelayedTransition(binding.constraintLayout)
|
||||
fun scrollToCurrentChapter() {
|
||||
val rv = binding.recyclerViewChapters
|
||||
if (!rv.isVisible) {
|
||||
return
|
||||
}
|
||||
binding.textViewTitle.text = item.manga.title
|
||||
val chapters = chaptersAdapter.items
|
||||
if (chapters.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val targetPos = item.chaptersDownloaded.coerceIn(chapters.indices)
|
||||
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos, rv.height / 3)
|
||||
}
|
||||
|
||||
bind { payloads ->
|
||||
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.unknown)
|
||||
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
|
||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
|
||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga?.coverUrl)?.apply {
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
allowRgb565(true)
|
||||
transformations(TrimTransformation())
|
||||
memoryCacheKey(item.coverCacheKey)
|
||||
source(item.manga.source)
|
||||
source(item.manga?.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
// binding.textViewTitle.isChecked = item.isExpanded
|
||||
// binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
|
||||
if (chaptersJob == null || payloads.isEmpty()) {
|
||||
chaptersJob?.cancel()
|
||||
chaptersJob = lifecycleOwner.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
item.chapters.collect { chapters ->
|
||||
binding.imageViewExpand.isGone = chapters.isNullOrEmpty()
|
||||
chaptersAdapter.emit(chapters)
|
||||
scrollToCurrentChapter()
|
||||
}
|
||||
}
|
||||
} else if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) {
|
||||
binding.recyclerViewChapters.post {
|
||||
scrollToCurrentChapter()
|
||||
}
|
||||
}
|
||||
binding.imageViewExpand.isChecked = item.isExpanded
|
||||
binding.recyclerViewChapters.isVisible = item.isExpanded
|
||||
when (item.workState) {
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.BLOCKED -> {
|
||||
|
||||
@@ -9,4 +9,6 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
|
||||
fun onPauseClick(item: DownloadItemModel)
|
||||
|
||||
fun onResumeClick(item: DownloadItemModel)
|
||||
|
||||
fun onExpandClick(item: DownloadItemModel)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.download.ui.list
|
||||
import android.text.format.DateUtils
|
||||
import androidx.work.WorkInfo
|
||||
import coil.memory.MemoryCache
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -14,7 +16,7 @@ data class DownloadItemModel(
|
||||
val workState: WorkInfo.State,
|
||||
val isIndeterminate: Boolean,
|
||||
val isPaused: Boolean,
|
||||
val manga: Manga,
|
||||
val manga: Manga?,
|
||||
val error: String?,
|
||||
val max: Int,
|
||||
val progress: Int,
|
||||
@@ -22,9 +24,10 @@ data class DownloadItemModel(
|
||||
val timestamp: Date,
|
||||
val chaptersDownloaded: Int,
|
||||
val isExpanded: Boolean,
|
||||
val chapters: StateFlow<List<DownloadChapter>?>,
|
||||
) : ListModel, Comparable<DownloadItemModel> {
|
||||
|
||||
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1"))
|
||||
val coverCacheKey = MemoryCache.Key(manga?.coverUrl.orEmpty(), mapOf("dl" to "1"))
|
||||
|
||||
val percent: Float
|
||||
get() = if (max > 0) progress / max.toFloat() else 0f
|
||||
@@ -38,9 +41,6 @@ data class DownloadItemModel(
|
||||
val canResume: Boolean
|
||||
get() = workState == WorkInfo.State.RUNNING && isPaused
|
||||
|
||||
val isExpandable: Boolean
|
||||
get() = false // TODO
|
||||
|
||||
fun getEtaString(): CharSequence? = if (hasEta) {
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
eta,
|
||||
|
||||
@@ -84,17 +84,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
|
||||
return
|
||||
}
|
||||
if (item.isExpandable) {
|
||||
viewModel.expandCollapse(item)
|
||||
} else {
|
||||
startActivity(DetailsActivity.newIntent(view.context, item.manga))
|
||||
}
|
||||
startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return))
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
|
||||
return selectionController.onItemLongClick(item.id.mostSignificantBits)
|
||||
}
|
||||
|
||||
override fun onExpandClick(item: DownloadItemModel) {
|
||||
if (!selectionController.onItemClick(item.id.mostSignificantBits)) {
|
||||
viewModel.expandCollapse(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelClick(item: DownloadItemModel) {
|
||||
viewModel.cancel(item.id)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package org.koitharu.kotatsu.download.ui.list
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.getOrElse
|
||||
import androidx.collection.set
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -27,12 +30,17 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.daysDiff
|
||||
import org.koitharu.kotatsu.core.util.ext.isEmpty
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -47,11 +55,15 @@ class DownloadsViewModel @Inject constructor(
|
||||
private val workScheduler: DownloadWorker.Scheduler,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val mangaCache = LongSparseArray<Manga>()
|
||||
private val cacheMutex = Mutex()
|
||||
private val expanded = MutableStateFlow(emptySet<UUID>())
|
||||
private val chaptersCache = ArrayMap<UUID, StateFlow<List<DownloadChapter>?>>()
|
||||
|
||||
private val works = combine(
|
||||
workScheduler.observeWorks(),
|
||||
expanded,
|
||||
@@ -234,10 +246,18 @@ class DownloadsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
|
||||
val workData = if (outputData == Data.EMPTY) progress else outputData
|
||||
val workData = outputData.takeUnless { it.isEmpty }
|
||||
?: progress.takeUnless { it.isEmpty }
|
||||
?: workScheduler.getInputData(id)
|
||||
?: return null
|
||||
val mangaId = DownloadState.getMangaId(workData)
|
||||
if (mangaId == 0L) return null
|
||||
val manga = getManga(mangaId) ?: return null
|
||||
val chapters = synchronized(chaptersCache) {
|
||||
chaptersCache.getOrPut(id) {
|
||||
observeChapters(manga, id)
|
||||
}
|
||||
}
|
||||
return DownloadItemModel(
|
||||
id = id,
|
||||
workState = state,
|
||||
@@ -251,6 +271,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
timestamp = DownloadState.getTimestamp(workData),
|
||||
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
|
||||
isExpanded = isExpanded,
|
||||
chapters = chapters,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -282,16 +303,42 @@ class DownloadsViewModel @Inject constructor(
|
||||
}
|
||||
return cacheMutex.withLock {
|
||||
mangaCache.getOrElse(mangaId) {
|
||||
mangaDataRepository.findMangaById(mangaId)?.let {
|
||||
tryLoad(it) ?: it
|
||||
}?.also {
|
||||
mangaDataRepository.findMangaById(mangaId)?.also {
|
||||
mangaCache[mangaId] = it
|
||||
} ?: return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeChapters(manga: Manga, workId: UUID): StateFlow<List<DownloadChapter>?> = flow {
|
||||
val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet()
|
||||
val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow
|
||||
|
||||
suspend fun mapChapters(): List<DownloadChapter> {
|
||||
val size = chapterIds?.size ?: chapters.size
|
||||
val localChapters =
|
||||
localMangaRepository.findSavedManga(manga)?.manga?.chapters?.mapToSet { it.id }.orEmpty()
|
||||
return chapters.mapNotNullTo(ArrayList(size)) {
|
||||
if (chapterIds == null || it.id in chapterIds) {
|
||||
DownloadChapter(
|
||||
number = it.number,
|
||||
name = it.name,
|
||||
isDownloaded = it.id in localChapters,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(mapChapters())
|
||||
localStorageChanges.collect {
|
||||
if (it?.manga?.id == manga.id) {
|
||||
emit(mapChapters())
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
|
||||
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga)
|
||||
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.download.ui.list.chapters
|
||||
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
data class DownloadChapter(
|
||||
@@ -11,4 +12,12 @@ data class DownloadChapter(
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is DownloadChapter && other.name == name
|
||||
}
|
||||
|
||||
override fun getChangePayload(previousState: ListModel): Any? {
|
||||
return if (previousState is DownloadChapter && previousState.name == name && previousState.number == number) {
|
||||
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED
|
||||
} else {
|
||||
super.getChangePayload(previousState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.download.ui.worker
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
@@ -55,6 +56,8 @@ import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteWork
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteWorks
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
@@ -121,7 +124,9 @@ class DownloadWorker @AssistedInject constructor(
|
||||
val notification = notificationFactory.create(currentState.copy(isStopped = true))
|
||||
notificationManager.notify(id.hashCode(), notification)
|
||||
}
|
||||
throw e
|
||||
Result.failure(
|
||||
currentState.copy(eta = -1L).toWorkData(),
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTraceDebug()
|
||||
Result.retry()
|
||||
@@ -417,6 +422,19 @@ class DownloadWorker @AssistedInject constructor(
|
||||
fun observeWorks(): Flow<List<WorkInfo>> = workManager
|
||||
.getWorkInfosByTagFlow(TAG)
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
suspend fun getInputData(id: UUID): Data? {
|
||||
val spec = workManager.getWorkSpec(id) ?: return null
|
||||
return Data.Builder()
|
||||
.putAll(spec.input)
|
||||
.putLong(DownloadState.DATA_TIMESTAMP, spec.scheduleRequestedAt)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun getInputChaptersIds(workId: UUID): LongArray? {
|
||||
return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
||||
}
|
||||
|
||||
suspend fun cancel(id: UUID) {
|
||||
workManager.cancelWorkById(id).await()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user