Use Voyager on Downloads screen (#8640)

This commit is contained in:
Ivan Iskandar 2022-11-28 21:23:11 +07:00 committed by GitHub
parent bcc21e55bd
commit cd13e187cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 566 additions and 558 deletions

View File

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

View File

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

View File

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

View File

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

View File

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