Use Voyager on Downloads screen (#8640)
This commit is contained in:
parent
bcc21e55bd
commit
cd13e187cf
5 changed files with 566 additions and 558 deletions
|
@ -7,19 +7,14 @@ import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||||
/**
|
/**
|
||||||
* Adapter storing a list of downloads.
|
* Adapter storing a list of downloads.
|
||||||
*
|
*
|
||||||
* @param context the context of the fragment containing this adapter.
|
* @param downloadItemListener Listener called when an item of the list is released.
|
||||||
*/
|
*/
|
||||||
class DownloadAdapter(controller: DownloadController) : FlexibleAdapter<AbstractFlexibleItem<*>>(
|
class DownloadAdapter(val downloadItemListener: DownloadItemListener) : FlexibleAdapter<AbstractFlexibleItem<*>>(
|
||||||
null,
|
null,
|
||||||
controller,
|
downloadItemListener,
|
||||||
true,
|
true,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
|
||||||
* Listener called when an item of the list is released.
|
|
||||||
*/
|
|
||||||
val downloadItemListener: DownloadItemListener = controller
|
|
||||||
|
|
||||||
override fun shouldMove(fromPosition: Int, toPosition: Int): Boolean {
|
override fun shouldMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||||
// Don't let sub-items changing group
|
// Don't let sub-items changing group
|
||||||
return getHeaderOf(getItem(fromPosition)) == getHeaderOf(getItem(toPosition))
|
return getHeaderOf(getItem(fromPosition)) == getHeaderOf(getItem(toPosition))
|
||||||
|
|
|
@ -1,496 +1,15 @@
|
||||||
package eu.kanade.tachiyomi.ui.download
|
package eu.kanade.tachiyomi.ui.download
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup.MarginLayoutParams
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.outlined.Pause
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import cafe.adriel.voyager.navigator.Navigator
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
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.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
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.updateLayoutParams
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import eu.kanade.presentation.components.AppBar
|
|
||||||
import eu.kanade.presentation.components.EmptyScreen
|
|
||||||
import eu.kanade.presentation.components.ExtendedFloatingActionButton
|
|
||||||
import eu.kanade.presentation.components.OverflowMenu
|
|
||||||
import eu.kanade.presentation.components.Pill
|
|
||||||
import eu.kanade.presentation.components.Scaffold
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
|
||||||
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
|
|
||||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
|
||||||
import rx.Observable
|
|
||||||
import rx.Subscription
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller that shows the currently active downloads.
|
* Controller that shows the currently active downloads.
|
||||||
*/
|
*/
|
||||||
class DownloadController :
|
class DownloadController : BasicFullComposeController() {
|
||||||
FullComposeController<DownloadPresenter>(),
|
|
||||||
DownloadAdapter.DownloadItemListener {
|
|
||||||
|
|
||||||
private lateinit var controllerBinding: DownloadListBinding
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter containing the active downloads.
|
|
||||||
*/
|
|
||||||
private var adapter: DownloadAdapter? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map of subscriptions for active downloads.
|
|
||||||
*/
|
|
||||||
private val progressSubscriptions by lazy { mutableMapOf<Download, Subscription>() }
|
|
||||||
|
|
||||||
override fun createPresenter() = DownloadPresenter()
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun ComposeContent() {
|
override fun ComposeContent() {
|
||||||
val context = LocalContext.current
|
Navigator(screen = DownloadQueueScreen)
|
||||||
val downloadList by presenter.state.collectAsState()
|
|
||||||
val downloadCount by remember {
|
|
||||||
derivedStateOf { downloadList.sumOf { it.subItems.size } }
|
|
||||||
}
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
var fabExpanded by remember { mutableStateOf(true) }
|
|
||||||
val nestedScrollConnection = remember {
|
|
||||||
// All this lines just for fab state :/
|
|
||||||
object : NestedScrollConnection {
|
|
||||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
|
||||||
fabExpanded = available.y >= 0
|
|
||||||
return scrollBehavior.nestedScrollConnection.onPreScroll(available, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
|
||||||
return scrollBehavior.nestedScrollConnection.onPostScroll(consumed, available, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
|
||||||
return scrollBehavior.nestedScrollConnection.onPreFling(available)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
|
||||||
return scrollBehavior.nestedScrollConnection.onPostFling(consumed, available)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
AppBar(
|
|
||||||
titleContent = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.label_download_queue),
|
|
||||||
maxLines = 1,
|
|
||||||
modifier = Modifier.weight(1f, false),
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
if (downloadCount > 0) {
|
|
||||||
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
|
|
||||||
Pill(
|
|
||||||
text = "$downloadCount",
|
|
||||||
modifier = Modifier.padding(start = 4.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
.copy(alpha = pillAlpha),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigateUp = router::popCurrentController,
|
|
||||||
actions = {
|
|
||||||
if (downloadList.isNotEmpty()) {
|
|
||||||
OverflowMenu { closeMenu ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(R.string.action_reorganize_by)) },
|
|
||||||
children = {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(R.string.action_order_by_upload_date)) },
|
|
||||||
children = {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(R.string.action_newest)) },
|
|
||||||
onClick = {
|
|
||||||
reorderQueue(
|
|
||||||
{ it.download.chapter.date_upload },
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
closeMenu()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(R.string.action_oldest)) },
|
|
||||||
onClick = {
|
|
||||||
reorderQueue(
|
|
||||||
{ it.download.chapter.date_upload },
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
closeMenu()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(R.string.action_order_by_chapter_number)) },
|
|
||||||
children = {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(R.string.action_asc)) },
|
|
||||||
onClick = {
|
|
||||||
reorderQueue(
|
|
||||||
{ it.download.chapter.chapter_number },
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
closeMenu()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(R.string.action_desc)) },
|
|
||||||
onClick = {
|
|
||||||
reorderQueue(
|
|
||||||
{ it.download.chapter.chapter_number },
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
closeMenu()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(R.string.action_cancel_all)) },
|
|
||||||
onClick = {
|
|
||||||
presenter.clearQueue(context)
|
|
||||||
closeMenu()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = downloadList.isNotEmpty(),
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
val isRunning by DownloadService.isRunning.collectAsState()
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
text = {
|
|
||||||
val id = if (isRunning) {
|
|
||||||
R.string.action_pause
|
|
||||||
} else {
|
|
||||||
R.string.action_resume
|
|
||||||
}
|
|
||||||
Text(text = stringResource(id))
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
val icon = if (isRunning) {
|
|
||||||
Icons.Outlined.Pause
|
|
||||||
} else {
|
|
||||||
Icons.Filled.PlayArrow
|
|
||||||
}
|
|
||||||
Icon(imageVector = icon, contentDescription = null)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (isRunning) {
|
|
||||||
DownloadService.stop(context)
|
|
||||||
presenter.pauseDownloads()
|
|
||||||
} else {
|
|
||||||
DownloadService.start(context)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
expanded = fabExpanded,
|
|
||||||
modifier = Modifier.navigationBarsPadding(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { contentPadding ->
|
|
||||||
if (downloadList.isEmpty()) {
|
|
||||||
EmptyScreen(
|
|
||||||
textResource = R.string.information_no_downloads,
|
|
||||||
modifier = Modifier.padding(contentPadding),
|
|
||||||
)
|
|
||||||
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(
|
|
||||||
factory = { context ->
|
|
||||||
controllerBinding = DownloadListBinding.inflate(LayoutInflater.from(context))
|
|
||||||
adapter = DownloadAdapter(this@DownloadController)
|
|
||||||
controllerBinding.recycler.adapter = adapter
|
|
||||||
adapter?.isHandleDragEnabled = true
|
|
||||||
adapter?.fastScroller = controllerBinding.fastScroller
|
|
||||||
controllerBinding.recycler.layoutManager = LinearLayoutManager(context)
|
|
||||||
|
|
||||||
ViewCompat.setNestedScrollingEnabled(controllerBinding.root, true)
|
|
||||||
|
|
||||||
viewScope.launchUI {
|
|
||||||
presenter.getDownloadStatusFlow()
|
|
||||||
.collect(this@DownloadController::onStatusChange)
|
|
||||||
}
|
|
||||||
viewScope.launchUI {
|
|
||||||
presenter.getDownloadProgressFlow()
|
|
||||||
.collect(this@DownloadController::onUpdateDownloadedPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
controllerBinding.root
|
|
||||||
},
|
|
||||||
update = {
|
|
||||||
controllerBinding.recycler
|
|
||||||
.updatePadding(
|
|
||||||
left = left,
|
|
||||||
top = top,
|
|
||||||
right = right,
|
|
||||||
bottom = bottom,
|
|
||||||
)
|
|
||||||
|
|
||||||
controllerBinding.fastScroller
|
|
||||||
.updateLayoutParams<MarginLayoutParams> {
|
|
||||||
leftMargin = left
|
|
||||||
topMargin = top
|
|
||||||
rightMargin = right
|
|
||||||
bottomMargin = bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter?.updateDataSet(downloadList)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView(view: View) {
|
|
||||||
for (subscription in progressSubscriptions.values) {
|
|
||||||
subscription.unsubscribe()
|
|
||||||
}
|
|
||||||
progressSubscriptions.clear()
|
|
||||||
adapter = null
|
|
||||||
super.onDestroyView(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newDownloads.addAll(headerItem.subItems.map { it.download })
|
|
||||||
}
|
|
||||||
presenter.reorder(newDownloads)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the status of a download changes.
|
|
||||||
*
|
|
||||||
* @param download the download whose status has changed.
|
|
||||||
*/
|
|
||||||
private fun onStatusChange(download: Download) {
|
|
||||||
when (download.status) {
|
|
||||||
Download.State.DOWNLOADING -> {
|
|
||||||
observeProgress(download)
|
|
||||||
// Initial update of the downloaded pages
|
|
||||||
onUpdateDownloadedPages(download)
|
|
||||||
}
|
|
||||||
Download.State.DOWNLOADED -> {
|
|
||||||
unsubscribeProgress(download)
|
|
||||||
onUpdateProgress(download)
|
|
||||||
onUpdateDownloadedPages(download)
|
|
||||||
}
|
|
||||||
Download.State.ERROR -> unsubscribeProgress(download)
|
|
||||||
else -> {
|
|
||||||
/* unused */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Observe the progress of a download and notify the view.
|
|
||||||
*
|
|
||||||
* @param download the download to observe its progress.
|
|
||||||
*/
|
|
||||||
private fun observeProgress(download: Download) {
|
|
||||||
val subscription = Observable.interval(50, TimeUnit.MILLISECONDS)
|
|
||||||
// Get the sum of percentages for all the pages.
|
|
||||||
.flatMap {
|
|
||||||
Observable.from(download.pages)
|
|
||||||
.map(Page::progress)
|
|
||||||
.reduce { x, y -> x + y }
|
|
||||||
}
|
|
||||||
// Keep only the latest emission to avoid backpressure.
|
|
||||||
.onBackpressureLatest()
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe { progress ->
|
|
||||||
// Update the view only if the progress has changed.
|
|
||||||
if (download.totalProgress != progress) {
|
|
||||||
download.totalProgress = progress
|
|
||||||
onUpdateProgress(download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid leaking subscriptions
|
|
||||||
progressSubscriptions.remove(download)?.unsubscribe()
|
|
||||||
|
|
||||||
progressSubscriptions[download] = subscription
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribes the given download from the progress subscriptions.
|
|
||||||
*
|
|
||||||
* @param download the download to unsubscribe.
|
|
||||||
*/
|
|
||||||
private fun unsubscribeProgress(download: Download) {
|
|
||||||
progressSubscriptions.remove(download)?.unsubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private 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.recycler.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
presenter.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 })
|
|
||||||
}
|
|
||||||
presenter.reorder(newDownloads)
|
|
||||||
}
|
|
||||||
R.id.move_to_top_series -> {
|
|
||||||
val (selectedSeries, otherSeries) = adapter?.currentItems
|
|
||||||
?.filterIsInstance<DownloadItem>()
|
|
||||||
?.map(DownloadItem::download)
|
|
||||||
?.partition { item.download.manga.id == it.manga.id }
|
|
||||||
?: Pair(emptyList(), emptyList())
|
|
||||||
presenter.reorder(selectedSeries + otherSeries)
|
|
||||||
}
|
|
||||||
R.id.cancel_download -> {
|
|
||||||
presenter.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()) {
|
|
||||||
presenter.cancel(allDownloadsForSeries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
package eu.kanade.tachiyomi.ui.download
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
|
||||||
import eu.kanade.tachiyomi.util.system.logcat
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import logcat.LogPriority
|
|
||||||
import uy.kohesive.injekt.Injekt
|
|
||||||
import uy.kohesive.injekt.api.get
|
|
||||||
|
|
||||||
class DownloadPresenter(
|
|
||||||
private val downloadManager: DownloadManager = Injekt.get(),
|
|
||||||
) : BasePresenter<DownloadController>() {
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(emptyList<DownloadHeaderItem>())
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
|
||||||
super.onCreate(savedState)
|
|
||||||
|
|
||||||
presenterScope.launch {
|
|
||||||
downloadManager.queue.updates
|
|
||||||
.catch { logcat(LogPriority.ERROR, it) }
|
|
||||||
.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) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.collect { newList -> _state.update { newList } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDownloadStatusFlow() = downloadManager.queue.statusFlow()
|
|
||||||
fun getDownloadProgressFlow() = downloadManager.queue.progressFlow()
|
|
||||||
|
|
||||||
fun pauseDownloads() {
|
|
||||||
downloadManager.pauseDownloads()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearQueue(context: Context) {
|
|
||||||
DownloadService.stop(context)
|
|
||||||
downloadManager.clearQueue()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reorder(downloads: List<Download>) {
|
|
||||||
downloadManager.reorderQueue(downloads)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(downloads: List<Download>) {
|
|
||||||
downloadManager.cancelQueuedDownloads(downloads)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,294 @@
|
||||||
|
package eu.kanade.tachiyomi.ui.download
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.outlined.Pause
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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
|
||||||
|
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.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.updateLayoutParams
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||||
|
import cafe.adriel.voyager.core.screen.Screen
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import eu.kanade.presentation.components.AppBar
|
||||||
|
import eu.kanade.presentation.components.EmptyScreen
|
||||||
|
import eu.kanade.presentation.components.ExtendedFloatingActionButton
|
||||||
|
import eu.kanade.presentation.components.OverflowMenu
|
||||||
|
import eu.kanade.presentation.components.Pill
|
||||||
|
import eu.kanade.presentation.components.Scaffold
|
||||||
|
import eu.kanade.presentation.util.LocalRouter
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||||
|
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
||||||
|
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
object DownloadQueueScreen : Screen {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val router = LocalRouter.currentOrThrow
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val screenModel = rememberScreenModel { DownloadQueueScreenModel() }
|
||||||
|
val downloadList by screenModel.state.collectAsState()
|
||||||
|
val downloadCount by remember {
|
||||||
|
derivedStateOf { downloadList.sumOf { it.subItems.size } }
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
var fabExpanded by remember { mutableStateOf(true) }
|
||||||
|
val nestedScrollConnection = remember {
|
||||||
|
// All this lines just for fab state :/
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||||
|
fabExpanded = available.y >= 0
|
||||||
|
return scrollBehavior.nestedScrollConnection.onPreScroll(available, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||||
|
return scrollBehavior.nestedScrollConnection.onPostScroll(consumed, available, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||||
|
return scrollBehavior.nestedScrollConnection.onPreFling(available)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
|
return scrollBehavior.nestedScrollConnection.onPostFling(consumed, available)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
AppBar(
|
||||||
|
titleContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_download_queue),
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.weight(1f, false),
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (downloadCount > 0) {
|
||||||
|
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
|
||||||
|
Pill(
|
||||||
|
text = "$downloadCount",
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
.copy(alpha = pillAlpha),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigateUp = router::popCurrentController,
|
||||||
|
actions = {
|
||||||
|
if (downloadList.isNotEmpty()) {
|
||||||
|
OverflowMenu { closeMenu ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(text = stringResource(R.string.action_reorganize_by)) },
|
||||||
|
children = {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(text = stringResource(R.string.action_order_by_upload_date)) },
|
||||||
|
children = {
|
||||||
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
|
text = { Text(text = stringResource(R.string.action_newest)) },
|
||||||
|
onClick = {
|
||||||
|
screenModel.reorderQueue(
|
||||||
|
{ it.download.chapter.date_upload },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
closeMenu()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
|
text = { Text(text = stringResource(R.string.action_oldest)) },
|
||||||
|
onClick = {
|
||||||
|
screenModel.reorderQueue(
|
||||||
|
{ it.download.chapter.date_upload },
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
closeMenu()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(text = stringResource(R.string.action_order_by_chapter_number)) },
|
||||||
|
children = {
|
||||||
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
|
text = { Text(text = stringResource(R.string.action_asc)) },
|
||||||
|
onClick = {
|
||||||
|
screenModel.reorderQueue(
|
||||||
|
{ it.download.chapter.chapter_number },
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
closeMenu()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
|
text = { Text(text = stringResource(R.string.action_desc)) },
|
||||||
|
onClick = {
|
||||||
|
screenModel.reorderQueue(
|
||||||
|
{ it.download.chapter.chapter_number },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
closeMenu()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
|
text = { Text(text = stringResource(R.string.action_cancel_all)) },
|
||||||
|
onClick = {
|
||||||
|
screenModel.clearQueue(context)
|
||||||
|
closeMenu()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = downloadList.isNotEmpty(),
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
val isRunning by DownloadService.isRunning.collectAsState()
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
text = {
|
||||||
|
val id = if (isRunning) {
|
||||||
|
R.string.action_pause
|
||||||
|
} else {
|
||||||
|
R.string.action_resume
|
||||||
|
}
|
||||||
|
Text(text = stringResource(id))
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
val icon = if (isRunning) {
|
||||||
|
Icons.Outlined.Pause
|
||||||
|
} else {
|
||||||
|
Icons.Filled.PlayArrow
|
||||||
|
}
|
||||||
|
Icon(imageVector = icon, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (isRunning) {
|
||||||
|
DownloadService.stop(context)
|
||||||
|
screenModel.pauseDownloads()
|
||||||
|
} else {
|
||||||
|
DownloadService.start(context)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expanded = fabExpanded,
|
||||||
|
modifier = Modifier.navigationBarsPadding(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { contentPadding ->
|
||||||
|
if (downloadList.isEmpty()) {
|
||||||
|
EmptyScreen(
|
||||||
|
textResource = R.string.information_no_downloads,
|
||||||
|
modifier = Modifier.padding(contentPadding),
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
factory = { context ->
|
||||||
|
screenModel.controllerBinding = DownloadListBinding.inflate(LayoutInflater.from(context))
|
||||||
|
screenModel.adapter = DownloadAdapter(screenModel.listener)
|
||||||
|
screenModel.controllerBinding.recycler.adapter = screenModel.adapter
|
||||||
|
screenModel.adapter?.isHandleDragEnabled = true
|
||||||
|
screenModel.adapter?.fastScroller = screenModel.controllerBinding.fastScroller
|
||||||
|
screenModel.controllerBinding.recycler.layoutManager = LinearLayoutManager(context)
|
||||||
|
|
||||||
|
ViewCompat.setNestedScrollingEnabled(screenModel.controllerBinding.root, true)
|
||||||
|
|
||||||
|
scope.launchUI {
|
||||||
|
screenModel.getDownloadStatusFlow()
|
||||||
|
.collect(screenModel::onStatusChange)
|
||||||
|
}
|
||||||
|
scope.launchUI {
|
||||||
|
screenModel.getDownloadProgressFlow()
|
||||||
|
.collect(screenModel::onUpdateDownloadedPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
screenModel.controllerBinding.root
|
||||||
|
},
|
||||||
|
update = {
|
||||||
|
screenModel.controllerBinding.recycler
|
||||||
|
.updatePadding(
|
||||||
|
left = left,
|
||||||
|
top = top,
|
||||||
|
right = right,
|
||||||
|
bottom = bottom,
|
||||||
|
)
|
||||||
|
|
||||||
|
screenModel.controllerBinding.fastScroller
|
||||||
|
.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
|
leftMargin = left
|
||||||
|
topMargin = top
|
||||||
|
rightMargin = right
|
||||||
|
bottomMargin = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
screenModel.adapter?.updateDataSet(downloadList)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,265 @@
|
||||||
|
package eu.kanade.tachiyomi.ui.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.MenuItem
|
||||||
|
import cafe.adriel.voyager.core.model.ScreenModel
|
||||||
|
import cafe.adriel.voyager.core.model.coroutineScope
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
|
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||||
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
|
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.util.system.logcat
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import logcat.LogPriority
|
||||||
|
import rx.Observable
|
||||||
|
import rx.Subscription
|
||||||
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class DownloadQueueScreenModel(
|
||||||
|
private val downloadManager: DownloadManager = Injekt.get(),
|
||||||
|
) : ScreenModel {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(emptyList<DownloadHeaderItem>())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
lateinit var controllerBinding: DownloadListBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter containing the active downloads.
|
||||||
|
*/
|
||||||
|
var adapter: DownloadAdapter? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of subscriptions for active downloads.
|
||||||
|
*/
|
||||||
|
val progressSubscriptions by lazy { mutableMapOf<Download, Subscription>() }
|
||||||
|
|
||||||
|
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 -> {
|
||||||
|
val (selectedSeries, otherSeries) = adapter?.currentItems
|
||||||
|
?.filterIsInstance<DownloadItem>()
|
||||||
|
?.map(DownloadItem::download)
|
||||||
|
?.partition { item.download.manga.id == it.manga.id }
|
||||||
|
?: Pair(emptyList(), emptyList())
|
||||||
|
reorder(selectedSeries + otherSeries)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
coroutineScope.launch {
|
||||||
|
downloadManager.queue.updates
|
||||||
|
.catch { logcat(LogPriority.ERROR, it) }
|
||||||
|
.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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.collect { newList -> _state.update { newList } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDispose() {
|
||||||
|
for (subscription in progressSubscriptions.values) {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
progressSubscriptions.clear()
|
||||||
|
adapter = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDownloadStatusFlow() = downloadManager.queue.statusFlow()
|
||||||
|
fun getDownloadProgressFlow() = downloadManager.queue.progressFlow()
|
||||||
|
|
||||||
|
fun pauseDownloads() {
|
||||||
|
downloadManager.pauseDownloads()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearQueue(context: Context) {
|
||||||
|
DownloadService.stop(context)
|
||||||
|
downloadManager.clearQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reorder(downloads: List<Download>) {
|
||||||
|
downloadManager.reorderQueue(downloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(downloads: List<Download>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
fun onStatusChange(download: Download) {
|
||||||
|
when (download.status) {
|
||||||
|
Download.State.DOWNLOADING -> {
|
||||||
|
observeProgress(download)
|
||||||
|
// Initial update of the downloaded pages
|
||||||
|
onUpdateDownloadedPages(download)
|
||||||
|
}
|
||||||
|
Download.State.DOWNLOADED -> {
|
||||||
|
unsubscribeProgress(download)
|
||||||
|
onUpdateProgress(download)
|
||||||
|
onUpdateDownloadedPages(download)
|
||||||
|
}
|
||||||
|
Download.State.ERROR -> unsubscribeProgress(download)
|
||||||
|
else -> {
|
||||||
|
/* unused */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe the progress of a download and notify the view.
|
||||||
|
*
|
||||||
|
* @param download the download to observe its progress.
|
||||||
|
*/
|
||||||
|
private fun observeProgress(download: Download) {
|
||||||
|
val subscription = Observable.interval(50, TimeUnit.MILLISECONDS)
|
||||||
|
// Get the sum of percentages for all the pages.
|
||||||
|
.flatMap {
|
||||||
|
Observable.from(download.pages)
|
||||||
|
.map(Page::progress)
|
||||||
|
.reduce { x, y -> x + y }
|
||||||
|
}
|
||||||
|
// Keep only the latest emission to avoid backpressure.
|
||||||
|
.onBackpressureLatest()
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { progress ->
|
||||||
|
// Update the view only if the progress has changed.
|
||||||
|
if (download.totalProgress != progress) {
|
||||||
|
download.totalProgress = progress
|
||||||
|
onUpdateProgress(download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid leaking subscriptions
|
||||||
|
progressSubscriptions.remove(download)?.unsubscribe()
|
||||||
|
|
||||||
|
progressSubscriptions[download] = subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribes the given download from the progress subscriptions.
|
||||||
|
*
|
||||||
|
* @param download the download to unsubscribe.
|
||||||
|
*/
|
||||||
|
private fun unsubscribeProgress(download: Download) {
|
||||||
|
progressSubscriptions.remove(download)?.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.recycler.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
|
||||||
|
}
|
||||||
|
}
|
Reference in a new issue