diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt index 227dc6fe71..44ff8b1103 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt @@ -7,19 +7,14 @@ import eu.davidea.flexibleadapter.items.AbstractFlexibleItem /** * 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>( +class DownloadAdapter(val downloadItemListener: DownloadItemListener) : FlexibleAdapter>( null, - controller, + downloadItemListener, true, ) { - /** - * Listener called when an item of the list is released. - */ - val downloadItemListener: DownloadItemListener = controller - override fun shouldMove(fromPosition: Int, toPosition: Int): Boolean { // Don't let sub-items changing group return getHeaderOf(getItem(fromPosition)) == getHeaderOf(getItem(toPosition)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt index cff0282fbf..cd2058e2ed 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt @@ -1,496 +1,15 @@ 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.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.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 +import cafe.adriel.voyager.navigator.Navigator +import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController /** * Controller that shows the currently active downloads. */ -class DownloadController : - FullComposeController(), - 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() } - - override fun createPresenter() = DownloadPresenter() - +class DownloadController : BasicFullComposeController() { @Composable override fun ComposeContent() { - val context = LocalContext.current - 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 { - 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 > reorderQueue(selector: (DownloadItem) -> R, reverse: Boolean = false) { - val adapter = adapter ?: return - val newDownloads = mutableListOf() - 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() - 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() - ?.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() - ?.filter { item.download.manga.id == it.download.manga.id } - ?.map(DownloadItem::download) - if (!allDownloadsForSeries.isNullOrEmpty()) { - presenter.cancel(allDownloadsForSeries) - } - } - } - } + Navigator(screen = DownloadQueueScreen) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt deleted file mode 100644 index 990b2fd23e..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt +++ /dev/null @@ -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() { - - private val _state = MutableStateFlow(emptyList()) - 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) { - downloadManager.reorderQueue(downloads) - } - - fun cancel(downloads: List) { - downloadManager.cancelQueuedDownloads(downloads) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreen.kt new file mode 100644 index 0000000000..b8010fe50e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreen.kt @@ -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 { + leftMargin = left + topMargin = top + rightMargin = right + bottomMargin = bottom + } + + screenModel.adapter?.updateDataSet(downloadList) + }, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreenModel.kt new file mode 100644 index 0000000000..17a1d1d635 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreenModel.kt @@ -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()) + 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() } + + 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() + 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() + ?.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() + ?.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) { + downloadManager.reorderQueue(downloads) + } + + fun cancel(downloads: List) { + downloadManager.cancelQueuedDownloads(downloads) + } + + fun > reorderQueue(selector: (DownloadItem) -> R, reverse: Boolean = false) { + val adapter = adapter ?: return + val newDownloads = mutableListOf() + 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 + } +}