Forráskód Böngészése

Use Voyager on Downloads screen (#8640)

Ivan Iskandar 2 éve
szülő
commit
cd13e187cf

+ 3 - 8
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<AbstractFlexibleItem<*>>(
+class DownloadAdapter(val downloadItemListener: DownloadItemListener) : FlexibleAdapter<AbstractFlexibleItem<*>>(
     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))

+ 4 - 485
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<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()
-
+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<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)
-                    }
-                }
-            }
-        }
+        Navigator(screen = DownloadQueueScreen)
     }
 }

+ 0 - 65
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt

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

+ 294 - 0
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<ViewGroup.MarginLayoutParams> {
+                                leftMargin = left
+                                topMargin = top
+                                rightMargin = right
+                                bottomMargin = bottom
+                            }
+
+                        screenModel.adapter?.updateDataSet(downloadList)
+                    },
+                )
+            }
+        }
+    }
+}

+ 265 - 0
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<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
+    }
+}