Show chapters list in downloads

This commit is contained in:
Koitharu
2023-10-17 12:19:44 +03:00
parent e172d619a1
commit c0e94f8415
13 changed files with 204 additions and 17 deletions

View File

@@ -128,6 +128,10 @@ class RemoteMangaRepository(
return details.await()
}
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, manga.title)
return list.find { x -> x.id == manga.id }

View File

@@ -19,6 +19,7 @@ data class DownloadState(
val eta: Long = -1L,
val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0),
val scheduledChapters: LongArray = LongArray(0),
val timestamp: Long = System.currentTimeMillis(),
) {
@@ -42,6 +43,7 @@ data class DownloadState(
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters)
.putLongArray(DATA_CHAPTERS_SRC, scheduledChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
.build()
@@ -64,10 +66,13 @@ data class DownloadState(
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (!scheduledChapters.contentEquals(other.scheduledChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
return percent == other.percent
if (percent != other.percent) return false
return true
}
override fun hashCode(): Int {
@@ -83,6 +88,7 @@ data class DownloadState(
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + scheduledChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
@@ -90,12 +96,14 @@ data class DownloadState(
return result
}
companion object {
private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_CHAPTERS_SRC = "chapters_src"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
@@ -119,5 +127,7 @@ data class DownloadState(
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0)
fun getScheduledChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS_SRC) ?: LongArray(0)
}
}

View File

@@ -1,18 +1,26 @@
package org.koitharu.kotatsu.download.ui.list
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
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.drawableEnd
import org.koitharu.kotatsu.core.util.ext.enqueueWith
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.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format
@@ -25,6 +33,9 @@ fun downloadItemAD(
) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
@@ -45,6 +56,8 @@ fun downloadItemAD(
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter
bind { payloads ->
binding.textViewTitle.text = item.manga.title
@@ -57,6 +70,10 @@ fun downloadItemAD(
source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.isChecked = item.isExpanded
binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
binding.cardDetails.isVisible = item.isExpanded
chaptersAdapter.items = item.chapters
when (item.workState) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> {

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils
import androidx.work.WorkInfo
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
@@ -19,6 +20,8 @@ data class DownloadItemModel(
val progress: Int,
val eta: Long,
val timestamp: Date,
val chapters: List<DownloadChapter>,
val isExpanded: Boolean,
) : ListModel, Comparable<DownloadItemModel> {
val percent: Float
@@ -33,6 +36,9 @@ data class DownloadItemModel(
val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean
get() = chapters.isNotEmpty()
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(
eta,

View File

@@ -82,7 +82,11 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
startActivity(DetailsActivity.newIntent(view.context, item.manga))
if (item.isExpandable) {
viewModel.expandCollapse(item)
} else {
startActivity(DetailsActivity.newIntent(view.context, item.manga))
}
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {

View File

@@ -8,15 +8,19 @@ import androidx.work.Data
import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -24,6 +28,7 @@ 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.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
@@ -31,6 +36,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import java.util.LinkedList
import java.util.UUID
@@ -41,13 +47,18 @@ import javax.inject.Inject
class DownloadsViewModel @Inject constructor(
private val workScheduler: DownloadWorker.Scheduler,
private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex()
private val works = workScheduler.observeWorks()
.mapLatest { it.toDownloadsList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val expanded = MutableStateFlow(emptySet<UUID>())
private val works = combine(
workScheduler.observeWorks(),
expanded,
) { list, exp ->
list.toDownloadsList(exp)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -169,11 +180,21 @@ class DownloadsViewModel @Inject constructor(
it.id.mostSignificantBits
} ?: emptySet()
private suspend fun List<WorkInfo>.toDownloadsList(): List<DownloadItemModel> {
fun expandCollapse(item: DownloadItemModel) {
expanded.update {
if (item.id in it) {
it - item.id
} else {
it + item.id
}
}
}
private suspend fun List<WorkInfo>.toDownloadsList(exp: Set<UUID>): List<DownloadItemModel> {
if (isEmpty()) {
return emptyList()
}
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() }
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel(it.id in exp) }
list.sortByDescending { it.timestamp }
return list
}
@@ -213,11 +234,13 @@ class DownloadsViewModel @Inject constructor(
return destination
}
private suspend fun WorkInfo.toUiModel(): DownloadItemModel? {
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
val workData = if (outputData == Data.EMPTY) progress else outputData
val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null
val downloadedChapters = DownloadState.getDownloadedChapters(workData)
val scheduledChapters = DownloadState.getScheduledChapters(workData).toSet()
return DownloadItemModel(
id = id,
workState = state,
@@ -229,7 +252,19 @@ class DownloadsViewModel @Inject constructor(
progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData),
totalChapters = DownloadState.getDownloadedChapters(workData).size,
totalChapters = downloadedChapters.size,
isExpanded = isExpanded,
chapters = manga.chapters?.mapNotNull {
if (it.id in scheduledChapters) {
DownloadChapter(
number = it.number,
name = it.name,
isDownloaded = it.id in downloadedChapters,
)
} else {
null
}
}.orEmpty(),
)
}
@@ -261,8 +296,16 @@ class DownloadsViewModel @Inject constructor(
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null
mangaDataRepository.findMangaById(mangaId)?.let {
tryLoad(it) ?: it
}?.also {
mangaCache[mangaId] = it
} ?: return null
}
}
}
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga)
}.getOrNull()
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.download.ui.list.chapters
import org.koitharu.kotatsu.list.ui.model.ListModel
data class DownloadChapter(
val number: Int,
val name: String,
val isDownloaded: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is DownloadChapter && other.name == name
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.download.ui.list.chapters
import androidx.core.content.ContextCompat
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.databinding.ItemChapterDownloadBinding
fun downloadChapterAD() = adapterDelegateViewBinding<DownloadChapter, DownloadChapter, ItemChapterDownloadBinding>(
{ layoutInflater, parent -> ItemChapterDownloadBinding.inflate(layoutInflater, parent, false) },
) {
val iconDone = ContextCompat.getDrawable(context, R.drawable.ic_check)
bind {
binding.textViewNumber.text = item.number.toString()
binding.textViewTitle.text = item.name
binding.textViewTitle.drawableEnd = if (item.isDownloaded) iconDone else null
}
}

View File

@@ -178,6 +178,9 @@ class DownloadWorker @AssistedInject constructor(
}
}
val chapters = getChapters(mangaDetails, includedIds)
publishState(
currentState.copy(scheduledChapters = LongArray(chapters.size) { i -> chapters[i].id }),
)
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) {
publishState(

View File

@@ -26,4 +26,5 @@ enum class ListItemType {
CATEGORY_LARGE,
MANGA_SCROBBLING,
NAV_ITEM,
CHAPTER,
}

View File

@@ -59,6 +59,7 @@ class TypedListSpacingDecoration(
ListItemType.MANGA_NESTED_GROUP,
ListItemType.CATEGORY_LARGE,
ListItemType.NAV_ITEM,
ListItemType.CHAPTER,
null,
-> outRect.set(0)
@@ -77,6 +78,6 @@ class TypedListSpacingDecoration(
private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing)
private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
}

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="4dp">
<TextView
android:id="@+id/textView_number"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="?android:listPreferredItemPaddingStart"
android:background="@drawable/bg_badge_default"
android:ellipsize="none"
android:gravity="center"
android:singleLine="true"
android:textAlignment="center"
android:textColor="?attr/colorOnPrimary"
android:textSize="12sp"
tools:text="13" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="?android:listPreferredItemPaddingStart"
android:layout_marginEnd="?android:listPreferredItemPaddingEnd"
android:drawablePadding="4dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodySmall"
app:drawableTint="?colorControlNormal"
tools:drawableEnd="@drawable/ic_check"
tools:text="@tools:sample/lorem[15]" />
</LinearLayout>

View File

@@ -24,7 +24,7 @@
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Medium"
tools:src="@tools:sample/backgrounds/scenic" />
<TextView
<CheckedTextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -32,11 +32,14 @@
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
app:drawableTint="?android:colorControlNormal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="parent"
tools:drawableEndCompat="@drawable/ic_expand_collapse"
tools:text="@tools:sample/lorem" />
<androidx.constraintlayout.widget.Barrier
@@ -100,6 +103,25 @@
app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_details"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:shapeAppearance="?shapeAppearanceCornerMedium">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_chapters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</com.google.android.material.card.MaterialCardView>
<Button
android:id="@+id/button_pause"
style="?materialButtonOutlinedStyle"
@@ -110,7 +132,7 @@
android:text="@string/pause"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_resume"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:layout_constraintTop_toBottomOf="@id/card_details"
tools:visibility="visible" />
<Button
@@ -123,7 +145,7 @@
android:text="@string/resume"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/progressBar" />
app:layout_constraintTop_toBottomOf="@id/card_details" />
<Button
android:id="@+id/button_cancel"
@@ -135,7 +157,7 @@
android:text="@android:string/cancel"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:layout_constraintTop_toBottomOf="@id/card_details"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>