mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-23 17:09:49 -07:00
Move the download queue list to Compose
This commit is contained in:
@@ -296,7 +296,6 @@ dependencies {
|
||||
|
||||
// UI libraries
|
||||
implementation(libs.material)
|
||||
implementation(libs.flexibleAdapter)
|
||||
implementation(libs.photoView)
|
||||
implementation(libs.directionalViewPager) {
|
||||
exclude(group = "androidx.viewpager", module = "viewpager")
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
package eu.kanade.presentation.download
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.gestures.ScrollScope
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.components.DropdownMenu
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.download.DownloadQueueSection
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
import mihon.icons.materialsymbols.rounded.DragHandle
|
||||
import mihon.icons.materialsymbols.rounded.MoreVert
|
||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
import sh.calvin.reorderable.rememberScroller
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.ListGroupHeader
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
fun DownloadQueueList(
|
||||
sections: List<DownloadQueueSection>,
|
||||
lazyListState: LazyListState,
|
||||
contentPadding: PaddingValues,
|
||||
onReorder: (List<Download>) -> Unit,
|
||||
onMove: (Download, toTop: Boolean) -> Unit,
|
||||
onMoveSeries: (Download, toTop: Boolean) -> Unit,
|
||||
onCancel: (Download) -> Unit,
|
||||
onCancelSeries: (Download) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sectionsState = remember { sections.toMutableStateList() }
|
||||
// While a section is dragged its downloads are hidden, so it moves past whole sections
|
||||
var isSectionDragging by remember { mutableStateOf(false) }
|
||||
// While a download is dragged only its own section's rows can be swapped with it
|
||||
var draggingSectionId by remember { mutableStateOf<Long?>(null) }
|
||||
// The release animation starts from the finger, so the last dragged row stays held while it settles too
|
||||
var lastDraggedKey by remember { mutableStateOf<String?>(null) }
|
||||
val listCoordinates = remember { CoordinatesHolder() }
|
||||
val draggingSection by rememberUpdatedState(sectionsState.find { it.id == draggingSectionId })
|
||||
// Auto-scroll would otherwise carry a download held at its section's edge off screen
|
||||
val sectionScrollState = remember(lazyListState) {
|
||||
LimitedScrollableState(lazyListState) { pixels ->
|
||||
draggingSection?.let { lazyListState.limitScrollToSection(it, pixels) } ?: pixels
|
||||
}
|
||||
}
|
||||
val reorderableState = rememberReorderableLazyListState(
|
||||
lazyListState = lazyListState,
|
||||
scrollThresholdPadding = contentPadding,
|
||||
scroller = rememberScroller(
|
||||
scrollableState = sectionScrollState,
|
||||
pixelAmountProvider = { lazyListState.layoutInfo.viewportSize.height * 0.05f },
|
||||
),
|
||||
) { from, to ->
|
||||
val fromKey = from.key as String
|
||||
val toKey = to.key as String
|
||||
when {
|
||||
fromKey.startsWith(SECTION_KEY) && toKey.startsWith(SECTION_KEY) -> {
|
||||
val fromIndex = sectionsState.indexOfFirst { it.key == fromKey }
|
||||
val toIndex = sectionsState.indexOfFirst { it.key == toKey }
|
||||
sectionsState.add(toIndex, sectionsState.removeAt(fromIndex))
|
||||
}
|
||||
// Downloads don't change section
|
||||
fromKey.startsWith(DOWNLOAD_KEY) && toKey.startsWith(DOWNLOAD_KEY) -> {
|
||||
val sectionIndex = sectionsState.indexOfFirst { section ->
|
||||
section.downloads.any { it.key == fromKey }
|
||||
}
|
||||
val section = sectionsState[sectionIndex]
|
||||
val toIndex = section.downloads.indexOfFirst { it.key == toKey }
|
||||
if (toIndex != -1) {
|
||||
val downloads = section.downloads.toMutableList()
|
||||
val fromIndex = downloads.indexOfFirst { it.key == fromKey }
|
||||
downloads.add(toIndex, downloads.removeAt(fromIndex))
|
||||
sectionsState[sectionIndex] = section.copy(downloads = downloads)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(sections) {
|
||||
if (!reorderableState.isAnyItemDragging) {
|
||||
sectionsState.clear()
|
||||
sectionsState.addAll(sections)
|
||||
}
|
||||
}
|
||||
|
||||
val onDragStopped = {
|
||||
isSectionDragging = false
|
||||
draggingSectionId = null
|
||||
onReorder(sectionsState.flatMap { it.downloads })
|
||||
}
|
||||
|
||||
val firstDownload = sectionsState.firstOrNull()?.downloads?.firstOrNull()
|
||||
val lastDownload = sectionsState.lastOrNull()?.downloads?.lastOrNull()
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { listCoordinates.value = it },
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
sectionsState.forEach { section ->
|
||||
item(key = section.key) {
|
||||
ReorderableItem(reorderableState, section.key, enabled = draggingSectionId == null) { isDragging ->
|
||||
DownloadQueueHeader(
|
||||
section = section,
|
||||
isDragging = isDragging,
|
||||
onDragStarted = { isSectionDragging = true },
|
||||
onDragStopped = onDragStopped,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!isSectionDragging) {
|
||||
items(
|
||||
items = section.downloads,
|
||||
key = { it.key },
|
||||
) { download ->
|
||||
ReorderableItem(
|
||||
state = reorderableState,
|
||||
key = download.key,
|
||||
enabled = draggingSectionId == null || draggingSectionId == section.id,
|
||||
) { isDragging ->
|
||||
DownloadQueueItem(
|
||||
download = download,
|
||||
isDragging = isDragging,
|
||||
modifier = if (isDragging || download.key == lastDraggedKey) {
|
||||
Modifier.keepInSection(
|
||||
lazyListState = lazyListState,
|
||||
listCoordinates = listCoordinates,
|
||||
sectionKey = section.key,
|
||||
lastDownloadKey = section.downloads.last().key,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
canMoveToTop = download != firstDownload,
|
||||
canMoveToBottom = download != lastDownload,
|
||||
onDragStarted = {
|
||||
draggingSectionId = section.id
|
||||
lastDraggedKey = download.key
|
||||
},
|
||||
onDragStopped = onDragStopped,
|
||||
onMove = { toTop -> onMove(download, toTop) },
|
||||
onMoveSeries = { toTop -> onMoveSeries(download, toTop) },
|
||||
onCancel = { onCancel(download) },
|
||||
onCancelSeries = { onCancelSeries(download) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy list keys must fit in a Bundle
|
||||
private const val SECTION_KEY = "section-"
|
||||
private const val DOWNLOAD_KEY = "download-"
|
||||
private val DownloadQueueSection.key get() = "$SECTION_KEY$id"
|
||||
private val Download.key get() = "$DOWNLOAD_KEY${chapter.id}"
|
||||
|
||||
private class CoordinatesHolder {
|
||||
var value: LayoutCoordinates? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* [lazyListState], with every scroll step passed through [limit] first.
|
||||
*/
|
||||
private class LimitedScrollableState(
|
||||
private val lazyListState: LazyListState,
|
||||
private val limit: (Float) -> Float,
|
||||
) : ScrollableState by lazyListState {
|
||||
override suspend fun scroll(scrollPriority: MutatePriority, block: suspend ScrollScope.() -> Unit) {
|
||||
lazyListState.scroll(scrollPriority) {
|
||||
val scope = this
|
||||
object : ScrollScope {
|
||||
override fun scrollBy(pixels: Float) = scope.scrollBy(limit(pixels))
|
||||
}.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits [pixels] so the list scrolls no further than the end of [section] reaching the bottom, or its header
|
||||
* reaching the top, while that edge is on screen.
|
||||
*/
|
||||
private fun LazyListState.limitScrollToSection(section: DownloadQueueSection, pixels: Float): Float {
|
||||
val items = layoutInfo.visibleItemsInfo
|
||||
return if (pixels > 0) {
|
||||
val last = items.firstOrNull { it.key == section.downloads.last().key } ?: return pixels
|
||||
val contentEnd = layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding
|
||||
pixels.coerceAtMost((last.offset + last.size - contentEnd).coerceAtLeast(0).toFloat())
|
||||
} else {
|
||||
val header = items.firstOrNull { it.key == section.key } ?: return pixels
|
||||
pixels.coerceAtLeast((header.offset).coerceAtMost(0).toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a dragged download at the edges of its section, between the header and the section's last slot, instead of
|
||||
* letting it float over another source it can't be dropped in.
|
||||
*/
|
||||
@Composable
|
||||
private fun Modifier.keepInSection(
|
||||
lazyListState: LazyListState,
|
||||
listCoordinates: CoordinatesHolder,
|
||||
sectionKey: String,
|
||||
lastDownloadKey: String,
|
||||
): Modifier {
|
||||
var correction by remember { mutableFloatStateOf(0f) }
|
||||
return this
|
||||
// Before the correction layer, so it sees where the drag put the row
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val list = listCoordinates.value?.takeIf { it.isAttached } ?: return@onGloballyPositioned
|
||||
val top = list.localPositionOf(coordinates, Offset.Zero).y
|
||||
val layoutInfo = lazyListState.layoutInfo
|
||||
val itemStart = layoutInfo.beforeContentPadding
|
||||
val header = layoutInfo.visibleItemsInfo.firstOrNull { it.key == sectionKey }
|
||||
val last = layoutInfo.visibleItemsInfo.firstOrNull { it.key == lastDownloadKey }
|
||||
val minTop = header?.let { (itemStart + it.offset + it.size).toFloat() } ?: Float.NEGATIVE_INFINITY
|
||||
val maxTop = last?.let { (itemStart + it.offset + it.size - coordinates.size.height).toFloat() }
|
||||
?: Float.POSITIVE_INFINITY
|
||||
correction = top.coerceIn(minTop, maxOf(minTop, maxTop)) - top
|
||||
}
|
||||
.graphicsLayer { translationY = correction }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReorderableCollectionItemScope.DownloadQueueHeader(
|
||||
section: DownloadQueueSection,
|
||||
isDragging: Boolean,
|
||||
onDragStarted: () -> Unit,
|
||||
onDragStopped: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(top = MaterialTheme.padding.small),
|
||||
color = listItemContainerColor(isDragging),
|
||||
shadowElevation = listItemElevation(isDragging),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = MaterialTheme.padding.small),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ListGroupHeader(
|
||||
text = "${section.name} (${section.downloads.size})",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReorderableCollectionItemScope.DownloadQueueItem(
|
||||
download: Download,
|
||||
isDragging: Boolean,
|
||||
canMoveToTop: Boolean,
|
||||
canMoveToBottom: Boolean,
|
||||
onDragStarted: () -> Unit,
|
||||
onDragStopped: () -> Unit,
|
||||
onMove: (toTop: Boolean) -> Unit,
|
||||
onMoveSeries: (toTop: Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onCancelSeries: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val progress by remember(download) { download.pagesProgressFlow() }.collectAsState(null)
|
||||
val animatedProgress by animateFloatAsState(progress?.fraction ?: 0f, label = "progress")
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = listItemContainerColor(isDragging),
|
||||
shadowElevation = listItemElevation(isDragging),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
start = MaterialTheme.padding.small,
|
||||
end = MaterialTheme.padding.extraSmall,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = MaterialTheme.padding.small),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
|
||||
Text(
|
||||
text = download.manga.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
progress?.let {
|
||||
Text(
|
||||
text = "${it.downloadedPages}/${it.pageCount}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = download.chapter.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = MaterialTheme.padding.extraSmall),
|
||||
)
|
||||
}
|
||||
DownloadQueueItemMenu(
|
||||
canMoveToTop = canMoveToTop,
|
||||
canMoveToBottom = canMoveToBottom,
|
||||
onMove = onMove,
|
||||
onMoveSeries = onMoveSeries,
|
||||
onCancel = onCancel,
|
||||
onCancelSeries = onCancelSeries,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReorderableCollectionItemScope.DragHandle(onDragStarted: () -> Unit, onDragStopped: () -> Unit) {
|
||||
Icon(
|
||||
imageVector = MaterialSymbols.Rounded.DragHandle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.draggableHandle(
|
||||
onDragStarted = { onDragStarted() },
|
||||
onDragStopped = onDragStopped,
|
||||
)
|
||||
.padding(MaterialTheme.padding.small),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadQueueItemMenu(
|
||||
canMoveToTop: Boolean,
|
||||
canMoveToBottom: Boolean,
|
||||
onMove: (toTop: Boolean) -> Unit,
|
||||
onMoveSeries: (toTop: Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onCancelSeries: () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val closeMenu = { expanded = false }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
imageVector = MaterialSymbols.Rounded.MoreVert,
|
||||
contentDescription = stringResource(MR.strings.action_menu),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = closeMenu) {
|
||||
if (canMoveToTop) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.action_move_to_top)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
onMove(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.action_move_to_top_all_for_series)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
onMoveSeries(true)
|
||||
},
|
||||
)
|
||||
if (canMoveToBottom) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.action_move_to_bottom)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
onMove(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.action_move_to_bottom_all_for_series)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
onMoveSeries(false)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.action_cancel)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
onCancel()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.cancel_all_for_series)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
onCancelSeries()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A neutral lifted surface while dragged, rather than Material's tertiaryContainer list tint
|
||||
@Composable
|
||||
private fun listItemContainerColor(isDragging: Boolean): Color {
|
||||
return if (isDragging) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.background
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun listItemElevation(isDragging: Boolean): Dp {
|
||||
val elevation = ListItemDefaults.elevation()
|
||||
return if (isDragging) elevation.draggedElevation else elevation.elevation
|
||||
}
|
||||
|
||||
private class PagesProgress(
|
||||
val pageCount: Int,
|
||||
val downloadedPages: Int,
|
||||
totalProgress: Int,
|
||||
) {
|
||||
val fraction = if (pageCount == 0) 0f else totalProgress / (pageCount * 100f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of the pages of this download, or null until its pages are known.
|
||||
*/
|
||||
private fun Download.pagesProgressFlow() = pagesFlow.flatMapLatest { pages ->
|
||||
when {
|
||||
pages == null -> flowOf(null)
|
||||
pages.isEmpty() -> flowOf(PagesProgress(pageCount = 0, downloadedPages = 0, totalProgress = 0))
|
||||
else ->
|
||||
combine(pages.map { page -> combine(page.statusFlow, page.progressFlow, ::Pair) }) { states ->
|
||||
PagesProgress(
|
||||
pageCount = pages.size,
|
||||
downloadedPages = states.count { (status, _) -> status == Page.State.Ready },
|
||||
totalProgress = states.sumOf { (_, progress) -> progress },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,6 @@ data class Download(
|
||||
_pagesFlow.value = pages
|
||||
}
|
||||
|
||||
val totalProgress: Int
|
||||
get() = pages?.sumOf(Page::progress) ?: 0
|
||||
|
||||
val downloadedImages: Int
|
||||
get() = pages?.count { it.status == Page.State.Ready } ?: 0
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.download
|
||||
|
||||
import android.view.MenuItem
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
|
||||
/**
|
||||
* Adapter storing a list of downloads.
|
||||
*
|
||||
* @param downloadItemListener Listener called when an item of the list is released.
|
||||
*/
|
||||
class DownloadAdapter(val downloadItemListener: DownloadItemListener) : FlexibleAdapter<AbstractFlexibleItem<*>>(
|
||||
null,
|
||||
downloadItemListener,
|
||||
true,
|
||||
) {
|
||||
|
||||
override fun shouldMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
// Don't let sub-items changing group
|
||||
return getHeaderOf(getItem(fromPosition)) == getHeaderOf(getItem(toPosition))
|
||||
}
|
||||
|
||||
interface DownloadItemListener {
|
||||
fun onItemReleased(position: Int)
|
||||
fun onMenuItemClick(position: Int, menuItem: MenuItem)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.download
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.viewholders.ExpandableViewHolder
|
||||
import eu.kanade.tachiyomi.databinding.DownloadHeaderBinding
|
||||
|
||||
class DownloadHeaderHolder(view: View, adapter: FlexibleAdapter<*>) : ExpandableViewHolder(view, adapter) {
|
||||
|
||||
private val binding = DownloadHeaderBinding.bind(view)
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun bind(item: DownloadHeaderItem) {
|
||||
setDragHandleView(binding.reorder)
|
||||
binding.title.text = "${item.name} (${item.size})"
|
||||
}
|
||||
|
||||
override fun onActionStateChanged(position: Int, actionState: Int) {
|
||||
super.onActionStateChanged(position, actionState)
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
binding.container.isDragged = true
|
||||
mAdapter.collapseAll()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemReleased(position: Int) {
|
||||
super.onItemReleased(position)
|
||||
binding.container.isDragged = false
|
||||
mAdapter.expandAll()
|
||||
(mAdapter as DownloadAdapter).downloadItemListener.onItemReleased(position)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.download
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
data class DownloadHeaderItem(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val size: Int,
|
||||
) : AbstractExpandableHeaderItem<DownloadHeaderHolder, DownloadItem>() {
|
||||
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.download_header
|
||||
}
|
||||
|
||||
override fun createViewHolder(
|
||||
view: View,
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
): DownloadHeaderHolder {
|
||||
return DownloadHeaderHolder(view, adapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: DownloadHeaderHolder,
|
||||
position: Int,
|
||||
payloads: List<Any?>?,
|
||||
) {
|
||||
holder.bind(this)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DownloadHeaderItem
|
||||
|
||||
if (id != other.id) return false
|
||||
if (name != other.name) return false
|
||||
if (size != other.size) return false
|
||||
if (subItemsCount != other.subItemsCount) return false
|
||||
if (subItems !== other.subItems) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + size
|
||||
result = 31 * result + subItems.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
init {
|
||||
isHidden = false
|
||||
isExpanded = true
|
||||
isSelectable = false
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.download
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.databinding.DownloadItemBinding
|
||||
import eu.kanade.tachiyomi.util.view.popupMenu
|
||||
|
||||
/**
|
||||
* Class used to hold the data of a download.
|
||||
* All the elements from the layout file "download_item" are available in this class.
|
||||
*
|
||||
* @param view the inflated view for this holder.
|
||||
* @constructor creates a new download holder.
|
||||
*/
|
||||
class DownloadHolder(private val view: View, val adapter: DownloadAdapter) :
|
||||
FlexibleViewHolder(view, adapter) {
|
||||
|
||||
private val binding = DownloadItemBinding.bind(view)
|
||||
|
||||
init {
|
||||
setDragHandleView(binding.reorder)
|
||||
binding.menu.setOnClickListener { it.post { showPopupMenu(it) } }
|
||||
}
|
||||
|
||||
private lateinit var download: Download
|
||||
|
||||
/**
|
||||
* Binds this holder with the given category.
|
||||
*
|
||||
* @param category The category to bind.
|
||||
*/
|
||||
fun bind(download: Download) {
|
||||
this.download = download
|
||||
// Update the chapter name.
|
||||
binding.chapterTitle.text = download.chapter.name
|
||||
|
||||
// Update the manga title
|
||||
binding.mangaFullTitle.text = download.manga.title
|
||||
|
||||
// Update the progress bar and the number of downloaded pages
|
||||
val pages = download.pages
|
||||
if (pages == null) {
|
||||
binding.downloadProgress.progress = 0
|
||||
binding.downloadProgress.max = 1
|
||||
binding.downloadProgressText.text = ""
|
||||
} else {
|
||||
binding.downloadProgress.max = pages.size * 100
|
||||
notifyProgress()
|
||||
notifyDownloadedPages()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the progress bar of the download.
|
||||
*/
|
||||
fun notifyProgress() {
|
||||
val pages = download.pages ?: return
|
||||
if (binding.downloadProgress.max == 1) {
|
||||
binding.downloadProgress.max = pages.size * 100
|
||||
}
|
||||
binding.downloadProgress.setProgressCompat(download.totalProgress, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the text field of the number of downloaded pages.
|
||||
*/
|
||||
fun notifyDownloadedPages() {
|
||||
val pages = download.pages ?: return
|
||||
binding.downloadProgressText.text = "${download.downloadedImages}/${pages.size}"
|
||||
}
|
||||
|
||||
override fun onItemReleased(position: Int) {
|
||||
super.onItemReleased(position)
|
||||
adapter.downloadItemListener.onItemReleased(position)
|
||||
binding.container.isDragged = false
|
||||
}
|
||||
|
||||
override fun onActionStateChanged(position: Int, actionState: Int) {
|
||||
super.onActionStateChanged(position, actionState)
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
binding.container.isDragged = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPopupMenu(view: View) {
|
||||
view.popupMenu(
|
||||
menuRes = R.menu.download_single,
|
||||
initMenu = {
|
||||
findItem(R.id.move_to_top).isVisible = bindingAdapterPosition > 1
|
||||
findItem(R.id.move_to_bottom).isVisible =
|
||||
bindingAdapterPosition != adapter.itemCount - 1
|
||||
},
|
||||
onMenuItemClick = {
|
||||
adapter.downloadItemListener.onMenuItemClick(bindingAdapterPosition, this)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.download
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
|
||||
class DownloadItem(
|
||||
val download: Download,
|
||||
header: DownloadHeaderItem,
|
||||
) : AbstractSectionableItem<DownloadHolder, DownloadHeaderItem>(header) {
|
||||
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.download_item
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new view holder for this item.
|
||||
*
|
||||
* @param view The view of this item.
|
||||
* @param adapter The adapter of this item.
|
||||
*/
|
||||
override fun createViewHolder(
|
||||
view: View,
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
): DownloadHolder {
|
||||
return DownloadHolder(view, adapter as DownloadAdapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the given view holder with this item.
|
||||
*
|
||||
* @param adapter The adapter of this item.
|
||||
* @param holder The holder to bind.
|
||||
* @param position The position of this item in the adapter.
|
||||
* @param payloads List of partial changes.
|
||||
*/
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: DownloadHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>,
|
||||
) {
|
||||
holder.bind(download)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this item is draggable.
|
||||
*/
|
||||
override fun isDraggable(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other is DownloadItem) {
|
||||
return download.chapter.id == other.download.chapter.id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return download.chapter.id.toInt()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package eu.kanade.tachiyomi.ui.download
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -19,7 +17,6 @@ import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -27,17 +24,11 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import dev.zacsweers.metrox.viewmodel.metroViewModel
|
||||
@@ -45,10 +36,8 @@ import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarActions
|
||||
import eu.kanade.presentation.components.DropdownMenu
|
||||
import eu.kanade.presentation.components.NestedMenuItem
|
||||
import eu.kanade.presentation.download.DownloadQueueList
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
import mihon.icons.materialsymbols.automirroredrounded.Sort
|
||||
import mihon.icons.materialsymbols.roundedfilled.Pause
|
||||
@@ -58,18 +47,16 @@ import tachiyomi.presentation.core.components.Pill
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object DownloadQueueScreen : Screen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewModel = metroViewModel<DownloadQueueViewModel>()
|
||||
val downloadList by viewModel.state.collectAsStateWithLifecycle()
|
||||
val downloadCount by remember {
|
||||
derivedStateOf { downloadList.sumOf { it.subItems.size } }
|
||||
derivedStateOf { downloadList.sumOf { it.downloads.size } }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
@@ -135,7 +122,7 @@ object DownloadQueueScreen : Screen() {
|
||||
text = { Text(text = stringResource(MR.strings.action_newest)) },
|
||||
onClick = {
|
||||
viewModel.reorderQueue(
|
||||
{ it.download.chapter.dateUpload },
|
||||
{ it.chapter.dateUpload },
|
||||
true,
|
||||
)
|
||||
closeMenu()
|
||||
@@ -145,7 +132,7 @@ object DownloadQueueScreen : Screen() {
|
||||
text = { Text(text = stringResource(MR.strings.action_oldest)) },
|
||||
onClick = {
|
||||
viewModel.reorderQueue(
|
||||
{ it.download.chapter.dateUpload },
|
||||
{ it.chapter.dateUpload },
|
||||
false,
|
||||
)
|
||||
closeMenu()
|
||||
@@ -160,7 +147,7 @@ object DownloadQueueScreen : Screen() {
|
||||
text = { Text(text = stringResource(MR.strings.action_asc)) },
|
||||
onClick = {
|
||||
viewModel.reorderQueue(
|
||||
{ it.download.chapter.chapterNumber },
|
||||
{ it.chapter.chapterNumber },
|
||||
false,
|
||||
)
|
||||
closeMenu()
|
||||
@@ -170,7 +157,7 @@ object DownloadQueueScreen : Screen() {
|
||||
text = { Text(text = stringResource(MR.strings.action_desc)) },
|
||||
onClick = {
|
||||
viewModel.reorderQueue(
|
||||
{ it.download.chapter.chapterNumber },
|
||||
{ it.chapter.chapterNumber },
|
||||
true,
|
||||
)
|
||||
closeMenu()
|
||||
@@ -240,49 +227,17 @@ object DownloadQueueScreen : Screen() {
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val left = with(density) { contentPadding.calculateLeftPadding(layoutDirection).toPx().roundToInt() }
|
||||
val top = with(density) { contentPadding.calculateTopPadding().toPx().roundToInt() }
|
||||
val right = with(density) { contentPadding.calculateRightPadding(layoutDirection).toPx().roundToInt() }
|
||||
val bottom = with(density) { contentPadding.calculateBottomPadding().toPx().roundToInt() }
|
||||
|
||||
Box(modifier = Modifier.nestedScroll(nestedScrollConnection)) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = { context ->
|
||||
viewModel.controllerBinding = DownloadListBinding.inflate(LayoutInflater.from(context))
|
||||
viewModel.adapter = DownloadAdapter(viewModel.listener)
|
||||
viewModel.controllerBinding.root.adapter = viewModel.adapter
|
||||
viewModel.adapter?.isHandleDragEnabled = true
|
||||
viewModel.controllerBinding.root.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
ViewCompat.setNestedScrollingEnabled(viewModel.controllerBinding.root, true)
|
||||
|
||||
scope.launch(Dispatchers.Main) {
|
||||
viewModel.getDownloadStatusFlow()
|
||||
.collect(viewModel::onStatusChange)
|
||||
}
|
||||
scope.launch(Dispatchers.Main) {
|
||||
viewModel.getDownloadProgressFlow()
|
||||
.collect(viewModel::onUpdateDownloadedPages)
|
||||
}
|
||||
|
||||
viewModel.controllerBinding.root
|
||||
},
|
||||
update = {
|
||||
viewModel.controllerBinding.root
|
||||
.updatePadding(
|
||||
left = left,
|
||||
top = top,
|
||||
right = right,
|
||||
bottom = bottom,
|
||||
)
|
||||
|
||||
viewModel.adapter?.updateDataSet(downloadList)
|
||||
},
|
||||
)
|
||||
}
|
||||
DownloadQueueList(
|
||||
sections = downloadList,
|
||||
lazyListState = rememberLazyListState(),
|
||||
contentPadding = contentPadding,
|
||||
onReorder = viewModel::reorder,
|
||||
onMove = viewModel::move,
|
||||
onMoveSeries = viewModel::moveSeries,
|
||||
onCancel = { viewModel.cancel(listOf(it)) },
|
||||
onCancelSeries = viewModel::cancelSeries,
|
||||
modifier = Modifier.nestedScroll(nestedScrollConnection),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
package eu.kanade.tachiyomi.ui.download
|
||||
|
||||
import android.view.MenuItem
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Inject
|
||||
@@ -37,117 +26,18 @@ class DownloadQueueViewModel(
|
||||
private val downloadManager: DownloadManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<List<DownloadHeaderItem>> = downloadManager.queueState
|
||||
val state: StateFlow<List<DownloadQueueSection>> = downloadManager.queueState
|
||||
.map { downloads ->
|
||||
downloads
|
||||
.groupBy { it.source }
|
||||
.map { entry ->
|
||||
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
|
||||
addSubItems(0, entry.value.map { DownloadItem(it, this) })
|
||||
}
|
||||
}
|
||||
.map { (source, downloads) -> DownloadQueueSection(source.id, source.name, downloads) }
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
||||
|
||||
lateinit var controllerBinding: DownloadListBinding
|
||||
|
||||
/**
|
||||
* Adapter containing the active downloads.
|
||||
*/
|
||||
var adapter: DownloadAdapter? = null
|
||||
|
||||
/**
|
||||
* Map of jobs for active downloads.
|
||||
*/
|
||||
private val progressJobs = mutableMapOf<Download, Job>()
|
||||
|
||||
val listener = object : DownloadAdapter.DownloadItemListener {
|
||||
/**
|
||||
* Called when an item is released from a drag.
|
||||
*
|
||||
* @param position The position of the released item.
|
||||
*/
|
||||
override fun onItemReleased(position: Int) {
|
||||
val adapter = adapter ?: return
|
||||
val downloads = adapter.headerItems.flatMap { header ->
|
||||
adapter.getSectionItems(header).map { item ->
|
||||
(item as DownloadItem).download
|
||||
}
|
||||
}
|
||||
reorder(downloads)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the menu item of a download is pressed
|
||||
*
|
||||
* @param position The position of the item
|
||||
* @param menuItem The menu Item pressed
|
||||
*/
|
||||
override fun onMenuItemClick(position: Int, menuItem: MenuItem) {
|
||||
val item = adapter?.getItem(position) ?: return
|
||||
if (item is DownloadItem) {
|
||||
when (menuItem.itemId) {
|
||||
R.id.move_to_top, R.id.move_to_bottom -> {
|
||||
val headerItems = adapter?.headerItems ?: return
|
||||
val newDownloads = mutableListOf<Download>()
|
||||
headerItems.forEach { headerItem ->
|
||||
headerItem as DownloadHeaderItem
|
||||
if (headerItem == item.header) {
|
||||
headerItem.removeSubItem(item)
|
||||
if (menuItem.itemId == R.id.move_to_top) {
|
||||
headerItem.addSubItem(0, item)
|
||||
} else {
|
||||
headerItem.addSubItem(item)
|
||||
}
|
||||
}
|
||||
newDownloads.addAll(headerItem.subItems.map { it.download })
|
||||
}
|
||||
reorder(newDownloads)
|
||||
}
|
||||
R.id.move_to_top_series, R.id.move_to_bottom_series -> {
|
||||
val (selectedSeries, otherSeries) = adapter?.currentItems
|
||||
?.filterIsInstance<DownloadItem>()
|
||||
?.map(DownloadItem::download)
|
||||
?.partition { item.download.manga.id == it.manga.id }
|
||||
?: Pair(emptyList(), emptyList())
|
||||
if (menuItem.itemId == R.id.move_to_top_series) {
|
||||
reorder(selectedSeries + otherSeries)
|
||||
} else {
|
||||
reorder(otherSeries + selectedSeries)
|
||||
}
|
||||
}
|
||||
R.id.cancel_download -> {
|
||||
cancel(listOf(item.download))
|
||||
}
|
||||
R.id.cancel_series -> {
|
||||
val allDownloadsForSeries = adapter?.currentItems
|
||||
?.filterIsInstance<DownloadItem>()
|
||||
?.filter { item.download.manga.id == it.download.manga.id }
|
||||
?.map(DownloadItem::download)
|
||||
if (!allDownloadsForSeries.isNullOrEmpty()) {
|
||||
cancel(allDownloadsForSeries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (job in progressJobs.values) {
|
||||
job.cancel()
|
||||
}
|
||||
progressJobs.clear()
|
||||
adapter = null
|
||||
}
|
||||
|
||||
val isDownloaderRunning = downloadManager.isDownloaderRunning
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), false)
|
||||
|
||||
fun getDownloadStatusFlow() = downloadManager.statusFlow()
|
||||
fun getDownloadProgressFlow() = downloadManager.progressFlow()
|
||||
|
||||
fun startDownloads() {
|
||||
downloadManager.startDownloads()
|
||||
}
|
||||
@@ -168,102 +58,59 @@ class DownloadQueueViewModel(
|
||||
viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
|
||||
}
|
||||
|
||||
fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) {
|
||||
val adapter = adapter ?: return
|
||||
val newDownloads = mutableListOf<Download>()
|
||||
adapter.headerItems.forEach { headerItem ->
|
||||
headerItem as DownloadHeaderItem
|
||||
headerItem.subItems = headerItem.subItems.sortedBy(selector).toMutableList().apply {
|
||||
if (reverse) {
|
||||
reverse()
|
||||
/**
|
||||
* Moves [download] to the top or bottom of its section.
|
||||
*/
|
||||
fun move(download: Download, toTop: Boolean) {
|
||||
reorder(
|
||||
state.value.flatMap { section ->
|
||||
if (download in section.downloads) {
|
||||
val others = section.downloads - download
|
||||
if (toTop) listOf(download) + others else others + download
|
||||
} else {
|
||||
section.downloads
|
||||
}
|
||||
}
|
||||
newDownloads.addAll(headerItem.subItems.map { it.download })
|
||||
}
|
||||
reorder(newDownloads)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the status of a download changes.
|
||||
*
|
||||
* @param download the download whose status has changed.
|
||||
* Moves all the downloads of the manga of [download] to the top or bottom of the queue.
|
||||
*/
|
||||
fun onStatusChange(download: Download) {
|
||||
when (download.status) {
|
||||
Download.State.DOWNLOADING -> {
|
||||
launchProgressJob(download)
|
||||
// Initial update of the downloaded pages
|
||||
onUpdateDownloadedPages(download)
|
||||
}
|
||||
Download.State.DOWNLOADED -> {
|
||||
cancelProgressJob(download)
|
||||
onUpdateProgress(download)
|
||||
onUpdateDownloadedPages(download)
|
||||
}
|
||||
Download.State.ERROR -> cancelProgressJob(download)
|
||||
else -> {
|
||||
/* unused */
|
||||
}
|
||||
fun moveSeries(download: Download, toTop: Boolean) {
|
||||
val (selectedSeries, otherSeries) = state.value
|
||||
.flatMap { it.downloads }
|
||||
.partition { it.manga.id == download.manga.id }
|
||||
reorder(if (toTop) selectedSeries + otherSeries else otherSeries + selectedSeries)
|
||||
}
|
||||
|
||||
fun cancelSeries(download: Download) {
|
||||
val allDownloadsForSeries = state.value
|
||||
.flatMap { it.downloads }
|
||||
.filter { it.manga.id == download.manga.id }
|
||||
if (allDownloadsForSeries.isNotEmpty()) {
|
||||
cancel(allDownloadsForSeries)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the progress of a download and notify the view.
|
||||
*
|
||||
* @param download the download to observe its progress.
|
||||
* Sorts the downloads of every section by [selector].
|
||||
*/
|
||||
private fun launchProgressJob(download: Download) {
|
||||
val job = viewModelScope.launch {
|
||||
val pages = download.pagesFlow.filterNotNull().first()
|
||||
val progressFlows = pages.map(Page::progressFlow)
|
||||
combine(progressFlows, Array<Int>::sum)
|
||||
.distinctUntilChanged()
|
||||
.debounce(50.milliseconds)
|
||||
.collectLatest {
|
||||
onUpdateProgress(download)
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid leaking jobs
|
||||
progressJobs.remove(download)?.cancel()
|
||||
|
||||
progressJobs[download] = job
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes the given download from the progress subscriptions.
|
||||
*
|
||||
* @param download the download to unsubscribe.
|
||||
*/
|
||||
private fun cancelProgressJob(download: Download) {
|
||||
progressJobs.remove(download)?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the progress of a download changes.
|
||||
*
|
||||
* @param download the download whose progress has changed.
|
||||
*/
|
||||
private fun onUpdateProgress(download: Download) {
|
||||
getHolder(download)?.notifyProgress()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a page of a download is downloaded.
|
||||
*
|
||||
* @param download the download whose page has been downloaded.
|
||||
*/
|
||||
fun onUpdateDownloadedPages(download: Download) {
|
||||
getHolder(download)?.notifyDownloadedPages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the holder for the given download.
|
||||
*
|
||||
* @param download the download to find.
|
||||
* @return the holder of the download or null if it's not bound.
|
||||
*/
|
||||
private fun getHolder(download: Download): DownloadHolder? {
|
||||
return controllerBinding.root.findViewHolderForItemId(download.chapter.id) as? DownloadHolder
|
||||
fun <R : Comparable<R>> reorderQueue(selector: (Download) -> R?, reverse: Boolean = false) {
|
||||
reorder(
|
||||
state.value.flatMap { section ->
|
||||
section.downloads.sortedBy(selector).let { if (reverse) it.reversed() else it }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads of the queue from one source.
|
||||
*/
|
||||
@Immutable
|
||||
data class DownloadQueueSection(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val downloads: List<Download>,
|
||||
)
|
||||
|
||||
@@ -4,15 +4,9 @@ package eu.kanade.tachiyomi.util.view
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Rect
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.annotation.MenuRes
|
||||
import androidx.appcompat.R
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -58,33 +52,6 @@ fun ComposeView.setComposeContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a popup menu on top of this view.
|
||||
*
|
||||
* @param menuRes menu items to inflate the menu with.
|
||||
* @param initMenu function to execute when the menu after is inflated.
|
||||
* @param onMenuItemClick function to execute when a menu item is clicked.
|
||||
*/
|
||||
inline fun View.popupMenu(
|
||||
@MenuRes menuRes: Int,
|
||||
noinline initMenu: (Menu.() -> Unit)? = null,
|
||||
noinline onMenuItemClick: MenuItem.() -> Unit,
|
||||
): PopupMenu {
|
||||
val popup = PopupMenu(context, this, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0)
|
||||
popup.menuInflater.inflate(menuRes, popup.menu)
|
||||
|
||||
if (initMenu != null) {
|
||||
popup.menu.initMenu()
|
||||
}
|
||||
popup.setOnMenuItemClickListener {
|
||||
it.onMenuItemClick()
|
||||
true
|
||||
}
|
||||
|
||||
popup.show()
|
||||
return popup
|
||||
}
|
||||
|
||||
fun View?.isVisibleOnScreen(): Boolean {
|
||||
if (this == null) {
|
||||
return false
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item android:alpha="0.08" android:color="?attr/colorPrimary" app:state_dragged="true" />
|
||||
<item android:alpha="0.08" android:color="?attr/colorPrimary" android:state_activated="true" />
|
||||
<item android:color="@android:color/transparent" />
|
||||
</selector>
|
||||
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView 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:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
app:cardBackgroundColor="?android:attr/colorBackground"
|
||||
app:cardElevation="0dp"
|
||||
app:cardForegroundColor="@color/draggable_card_foreground">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
style="@style/TextAppearance.Tachiyomi.SectionHeader"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="8dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center_vertical"
|
||||
tools:text="Title" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/reorder"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="8dp"
|
||||
android:scaleType="center"
|
||||
app:srcCompat="@drawable/rounded_drag_handle"
|
||||
app:tint="?android:attr/textColorHint"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -1,104 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView 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:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="?android:attr/colorBackground"
|
||||
app:cardElevation="0dp"
|
||||
app:cardForegroundColor="@color/draggable_card_foreground">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingVertical="4dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/reorder"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_gravity="start"
|
||||
android:padding="16dp"
|
||||
android:scaleType="center"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/rounded_drag_handle"
|
||||
app:tint="?android:attr/textColorHint"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/manga_full_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_toEndOf="@id/reorder"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
app:layout_constraintEnd_toStartOf="@+id/download_progress_text"
|
||||
app:layout_constraintStart_toEndOf="@+id/reorder"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Manga title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chapter_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_toEndOf="@id/reorder"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/menu"
|
||||
app:layout_constraintStart_toStartOf="@+id/manga_full_title"
|
||||
app:layout_constraintTop_toBottomOf="@+id/manga_full_title"
|
||||
tools:text="Chapter Title" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/download_progress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/menu"
|
||||
app:layout_constraintStart_toEndOf="@+id/reorder"
|
||||
app:layout_constraintTop_toBottomOf="@+id/chapter_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/download_progress_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_toEndOf="@id/manga_full_title"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/manga_full_title"
|
||||
app:layout_constraintEnd_toStartOf="@+id/menu"
|
||||
app:layout_constraintTop_toTopOf="@+id/manga_full_title"
|
||||
tools:text="0/10" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/menu"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_toEndOf="@id/download_progress_text"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_menu"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/rounded_more_vert"
|
||||
app:tint="?attr/colorOnBackground" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
tools:listitem="@layout/download_item" />
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/move_to_top"
|
||||
android:title="@string/action_move_to_top" />
|
||||
|
||||
<item
|
||||
android:id="@+id/move_to_top_series"
|
||||
android:title="@string/action_move_to_top_all_for_series" />
|
||||
|
||||
<item
|
||||
android:id="@+id/move_to_bottom"
|
||||
android:title="@string/action_move_to_bottom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/move_to_bottom_series"
|
||||
android:title="@string/action_move_to_bottom_all_for_series" />
|
||||
|
||||
<item
|
||||
android:id="@+id/cancel_download"
|
||||
android:title="@string/action_cancel" />
|
||||
|
||||
<item
|
||||
android:id="@+id/cancel_series"
|
||||
android:title="@string/cancel_all_for_series" />
|
||||
|
||||
</menu>
|
||||
@@ -31,10 +31,6 @@
|
||||
|
||||
<style name="TextAppearance.Tachiyomi.Preference.Secondary" parent="TextAppearance.Material3.BodySmall" />
|
||||
|
||||
<style name="TextAppearance.Tachiyomi.SectionHeader" parent="TextAppearance.Material3.LabelLarge">
|
||||
<item name="android:textColor">?android:attr/textColorSecondary</item>
|
||||
</style>
|
||||
|
||||
|
||||
<!--=======================-->
|
||||
<!--Widgets.MaterialDivider-->
|
||||
|
||||
@@ -40,7 +40,6 @@ directionalViewPager = "1.0.0"
|
||||
diskLruCache = "2.0.2"
|
||||
firebase-bom = "35.0.2"
|
||||
firebase-crashlytics = "3.0.8"
|
||||
flexibleAdapter = "5.1.0"
|
||||
google-services = "4.5.0"
|
||||
image-decoder = "17"
|
||||
injekt = "1.16.1"
|
||||
@@ -141,7 +140,6 @@ diskLruCache = { module = "com.jakewharton:disklrucache", version.ref = "diskLru
|
||||
firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
|
||||
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
|
||||
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
|
||||
flexibleAdapter = { module = "eu.davidea:flexible-adapter", version.ref = "flexibleAdapter" }
|
||||
image-decoder = { module = "ca.mpreg:imagedecoder", version.ref = "image-decoder" }
|
||||
injekt = { module = "uy.kohesive.injekt:injekt-core", version.ref = "injekt" }
|
||||
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||
|
||||
@@ -71,9 +71,7 @@ androidComponents {
|
||||
"rounded/book",
|
||||
"rounded/check",
|
||||
"rounded/close",
|
||||
"rounded/drag_handle",
|
||||
"rounded/eyeglasses_2",
|
||||
"rounded/more_vert",
|
||||
"rounded/refresh",
|
||||
"rounded/share",
|
||||
"roundedFilled/extension",
|
||||
|
||||
Reference in New Issue
Block a user