Browse Source

DownloadController: Partial Compose conversion (#7969)

Item list is not changed as currently there is no fitting Compose component to
replace the drag-drop behavior.
Ivan Iskandar 2 năm trước cách đây
mục cha
commit
fb9791f597

+ 2 - 0
app/build.gradle.kts

@@ -73,6 +73,7 @@ android {
             signingConfig = debugType.signingConfig
             versionNameSuffix = debugType.versionNameSuffix
             applicationIdSuffix = debugType.applicationIdSuffix
+            matchingFallbacks.add("release")
         }
     }
 
@@ -252,6 +253,7 @@ dependencies {
     implementation(libs.insetter)
     implementation(libs.markwon)
     implementation(libs.aboutLibraries.compose)
+    implementation(libs.cascade)
 
     // Conductor
     implementation(libs.bundles.conductor)

+ 7 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt

@@ -25,6 +25,8 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
@@ -47,6 +49,9 @@ class DownloadService : Service() {
          */
         val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
 
+        private val _isRunning = MutableStateFlow(false)
+        val isRunning = _isRunning.asStateFlow()
+
         /**
          * Starts this service.
          *
@@ -98,6 +103,7 @@ class DownloadService : Service() {
         startForeground(Notifications.ID_DOWNLOAD_CHAPTER_PROGRESS, getPlaceholderNotification())
         wakeLock = acquireWakeLock(javaClass.name)
         runningRelay.call(true)
+        _isRunning.value = true
         subscriptions = CompositeSubscription()
         listenDownloaderState()
         listenNetworkChanges()
@@ -109,6 +115,7 @@ class DownloadService : Service() {
     override fun onDestroy() {
         ioScope?.cancel()
         runningRelay.call(false)
+        _isRunning.value = false
         subscriptions.unsubscribe()
         downloadManager.stopDownloads()
         wakeLock.releaseIfNeeded()

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt

@@ -83,6 +83,8 @@ class DownloadQueue(
         .startWith(Unit)
         .map { this }
 
+    fun getUpdatedAsFlow(): Flow<List<Download>> = getUpdatedObservable().asFlow()
+
     private fun setPagesFor(download: Download) {
         if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) {
             setPagesSubject(download.pages, null)

+ 269 - 177
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt

@@ -1,134 +1,318 @@
 package eu.kanade.tachiyomi.ui.download
 
 import android.view.LayoutInflater
-import android.view.Menu
-import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
-import androidx.core.view.isVisible
+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.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.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 androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
-import dev.chrisbanes.insetter.applyInsetter
+import eu.kanade.presentation.components.AppBar
+import eu.kanade.presentation.components.EmptyScreen
+import eu.kanade.presentation.components.ExtendedFloatingActionButton
+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.DownloadControllerBinding
+import eu.kanade.tachiyomi.databinding.DownloadListBinding
 import eu.kanade.tachiyomi.source.model.Page
-import eu.kanade.tachiyomi.ui.base.controller.FabController
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
-import eu.kanade.tachiyomi.util.view.shrinkOnScroll
+import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
+import eu.kanade.tachiyomi.util.lang.launchUI
+import me.saket.cascade.CascadeDropdownMenu
 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.
  * Uses R.layout.fragment_download_queue.
  */
 class DownloadController :
-    NucleusController<DownloadControllerBinding, DownloadPresenter>(),
-    FabController,
+    FullComposeController<DownloadPresenter>(),
     DownloadAdapter.DownloadItemListener {
 
+    private lateinit var controllerBinding: DownloadListBinding
+
     /**
      * Adapter containing the active downloads.
      */
     private var adapter: DownloadAdapter? = null
-    private var actionFab: ExtendedFloatingActionButton? = null
-    private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
 
     /**
      * Map of subscriptions for active downloads.
      */
     private val progressSubscriptions by lazy { mutableMapOf<Download, Subscription>() }
 
-    /**
-     * Whether the download queue is running or not.
-     */
-    private var isRunning: Boolean = false
-
-    init {
-        setHasOptionsMenu(true)
-    }
-
-    override fun createBinding(inflater: LayoutInflater) = DownloadControllerBinding.inflate(inflater)
-
-    override fun createPresenter(): DownloadPresenter {
-        return DownloadPresenter()
-    }
-
-    override fun getTitle(): String? {
-        return resources?.getString(R.string.label_download_queue)
-    }
+    override fun createPresenter() = DownloadPresenter()
 
     override fun onViewCreated(view: View) {
         super.onViewCreated(view)
 
-        binding.recycler.applyInsetter {
-            type(navigationBars = true) {
-                padding()
-            }
+        viewScope.launchUI {
+            presenter.getDownloadStatusFlow()
+                .collect(this@DownloadController::onStatusChange)
         }
+        viewScope.launchUI {
+            presenter.getDownloadProgressFlow()
+                .collect(this@DownloadController::onUpdateDownloadedPages)
+        }
+    }
 
-        // Check if download queue is empty and update information accordingly.
-        setInformationView()
-
-        // Initialize adapter.
-        adapter = DownloadAdapter(this@DownloadController)
-        binding.recycler.adapter = adapter
-        adapter?.isHandleDragEnabled = true
-        adapter?.fastScroller = binding.fastScroller
-
-        // Set the layout manager for the recycler and fixed size.
-        binding.recycler.layoutManager = LinearLayoutManager(view.context)
-        binding.recycler.setHasFixedSize(true)
-
-        actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
-
-        // Subscribe to changes
-        DownloadService.runningRelay
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeUntilDestroy { onQueueStatusChange(it) }
+    @Composable
+    override fun ComposeContent() {
+        val context = LocalContext.current
+        val downloadList by presenter.state.collectAsState()
+
+        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)
+                }
 
-        presenter.getDownloadStatusObservable()
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeUntilDestroy { onStatusChange(it) }
+                override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
+                    return scrollBehavior.nestedScrollConnection.onPostScroll(consumed, available, source)
+                }
 
-        presenter.getDownloadProgressObservable()
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeUntilDestroy { onUpdateDownloadedPages(it) }
+                override suspend fun onPreFling(available: Velocity): Velocity {
+                    return scrollBehavior.nestedScrollConnection.onPreFling(available)
+                }
 
-        presenter.downloadQueue.getUpdatedObservable()
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeUntilDestroy {
-                updateTitle(it.size)
+                override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+                    return scrollBehavior.nestedScrollConnection.onPostFling(consumed, available)
+                }
             }
-    }
-
-    override fun configureFab(fab: ExtendedFloatingActionButton) {
-        actionFab = fab
-        fab.setOnClickListener {
-            val context = applicationContext ?: return@setOnClickListener
+        }
 
-            if (isRunning) {
-                DownloadService.stop(context)
-                presenter.pauseDownloads()
-            } else {
-                DownloadService.start(context)
+        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 (downloadList.isNotEmpty()) {
+                                val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
+                                Pill(
+                                    text = "${downloadList.size}",
+                                    modifier = Modifier.padding(start = 4.dp),
+                                    color = MaterialTheme.colorScheme.onBackground
+                                        .copy(alpha = pillAlpha),
+                                    fontSize = 14.sp,
+                                )
+                            }
+                        }
+                    },
+                    navigateUp = router::popCurrentController,
+                    actions = {
+                        if (downloadList.isNotEmpty()) {
+                            val (expanded, onExpanded) = remember { mutableStateOf(false) }
+                            Box {
+                                IconButton(onClick = { onExpanded(!expanded) }) {
+                                    Icon(
+                                        imageVector = Icons.Outlined.MoreVert,
+                                        contentDescription = stringResource(R.string.label_more),
+                                    )
+                                }
+                                CascadeDropdownMenu(
+                                    expanded = expanded,
+                                    onDismissRequest = { onExpanded(false) },
+                                ) {
+                                    DropdownMenuItem(
+                                        text = { Text(text = stringResource(id = R.string.action_reorganize_by)) },
+                                        children = {
+                                            DropdownMenuItem(
+                                                text = { Text(text = stringResource(id = R.string.action_order_by_upload_date)) },
+                                                children = {
+                                                    DropdownMenuItem(
+                                                        text = { Text(text = stringResource(id = R.string.action_newest)) },
+                                                        onClick = {
+                                                            reorderQueue({ it.download.chapter.date_upload }, true)
+                                                            onExpanded(false)
+                                                        },
+                                                    )
+                                                    DropdownMenuItem(
+                                                        text = { Text(text = stringResource(id = R.string.action_oldest)) },
+                                                        onClick = {
+                                                            reorderQueue({ it.download.chapter.date_upload }, false)
+                                                            onExpanded(false)
+                                                        },
+                                                    )
+                                                },
+                                            )
+                                            DropdownMenuItem(
+                                                text = { Text(text = stringResource(id = R.string.action_order_by_chapter_number)) },
+                                                children = {
+                                                    DropdownMenuItem(
+                                                        text = { Text(text = stringResource(id = R.string.action_asc)) },
+                                                        onClick = {
+                                                            reorderQueue({ it.download.chapter.chapter_number }, false)
+                                                            onExpanded(false)
+                                                        },
+                                                    )
+                                                    DropdownMenuItem(
+                                                        text = { Text(text = stringResource(id = R.string.action_desc)) },
+                                                        onClick = {
+                                                            reorderQueue({ it.download.chapter.chapter_number }, true)
+                                                            onExpanded(false)
+                                                        },
+                                                    )
+                                                },
+                                            )
+                                        },
+                                    )
+                                    DropdownMenuItem(
+                                        text = { Text(text = stringResource(id = R.string.action_cancel_all)) },
+                                        onClick = {
+                                            presenter.clearQueue(context)
+                                            onExpanded(false)
+                                        },
+                                    )
+                                }
+                            }
+                        }
+                    },
+                    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.Default.Pause
+                            } else {
+                                Icons.Default.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)
+                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)
+
+                        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
+                            }
 
-            setInformationView()
+                        adapter?.updateDataSet(downloadList)
+                    },
+                )
+            }
         }
     }
 
-    override fun cleanupFab(fab: ExtendedFloatingActionButton) {
-        fab.setOnClickListener(null)
-        actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
-        actionFab = null
-    }
-
     override fun onDestroyView(view: View) {
         for (subscription in progressSubscriptions.values) {
             subscription.unsubscribe()
@@ -138,32 +322,6 @@ class DownloadController :
         super.onDestroyView(view)
     }
 
-    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
-        inflater.inflate(R.menu.download_queue, menu)
-    }
-
-    override fun onPrepareOptionsMenu(menu: Menu) {
-        menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
-        menu.findItem(R.id.reorder).isVisible = !presenter.downloadQueue.isEmpty()
-    }
-
-    override fun onOptionsItemSelected(item: MenuItem): Boolean {
-        val context = applicationContext ?: return false
-        when (item.itemId) {
-            R.id.clear_queue -> {
-                DownloadService.stop(context)
-                presenter.clearQueue()
-            }
-            R.id.newest, R.id.oldest -> {
-                reorderQueue({ it.download.chapter.date_upload }, item.itemId == R.id.newest)
-            }
-            R.id.asc, R.id.desc -> {
-                reorderQueue({ it.download.chapter.chapter_number }, item.itemId == R.id.desc)
-            }
-        }
-        return super.onOptionsItemSelected(item)
-    }
-
     private fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R, reverse: Boolean = false) {
         val adapter = adapter ?: return
         val newDownloads = mutableListOf<Download>()
@@ -242,30 +400,6 @@ class DownloadController :
         progressSubscriptions.remove(download)?.unsubscribe()
     }
 
-    /**
-     * Called when the queue's status has changed. Updates the visibility of the buttons.
-     *
-     * @param running whether the queue is now running or not.
-     */
-    private fun onQueueStatusChange(running: Boolean) {
-        isRunning = running
-        activity?.invalidateOptionsMenu()
-
-        // Check if download queue is empty and update information accordingly.
-        setInformationView()
-    }
-
-    /**
-     * Called from the presenter to assign the downloads for the adapter.
-     *
-     * @param downloads the downloads from the queue.
-     */
-    fun onNextDownloads(downloads: List<DownloadHeaderItem>) {
-        activity?.invalidateOptionsMenu()
-        setInformationView()
-        adapter?.updateDataSet(downloads)
-    }
-
     /**
      * Called when the progress of a download changes.
      *
@@ -291,39 +425,7 @@ class DownloadController :
      * @return the holder of the download or null if it's not bound.
      */
     private fun getHolder(download: Download): DownloadHolder? {
-        return binding.recycler.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
-    }
-
-    /**
-     * Set information view when queue is empty
-     */
-    private fun setInformationView() {
-        if (presenter.downloadQueue.isEmpty()) {
-            binding.emptyView.show(R.string.information_no_downloads)
-            actionFab?.isVisible = false
-            updateTitle()
-        } else {
-            binding.emptyView.hide()
-            actionFab?.apply {
-                isVisible = true
-
-                setText(
-                    if (isRunning) {
-                        R.string.action_pause
-                    } else {
-                        R.string.action_resume
-                    },
-                )
-
-                setIconResource(
-                    if (isRunning) {
-                        R.drawable.ic_pause_24dp
-                    } else {
-                        R.drawable.ic_play_arrow_24dp
-                    },
-                )
-            }
-        }
+        return controllerBinding.recycler.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
     }
 
     /**
@@ -373,7 +475,7 @@ class DownloadController :
                         ?.filterIsInstance<DownloadItem>()
                         ?.map(DownloadItem::download)
                         ?.partition { item.download.manga.id == it.manga.id }
-                        ?: Pair(listOf<Download>(), listOf<Download>())
+                        ?: Pair(listOf(), listOf())
                     presenter.reorder(selectedSeries + otherSeries)
                 }
                 R.id.cancel_download -> {
@@ -391,14 +493,4 @@ class DownloadController :
             }
         }
     }
-
-    private fun updateTitle(queueSize: Int = 0) {
-        val defaultTitle = getTitle()
-
-        if (queueSize == 0) {
-            setTitle(defaultTitle)
-        } else {
-            setTitle("$defaultTitle ($queueSize)")
-        }
-    }
 }

+ 16 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHeaderItem.kt

@@ -35,14 +35,25 @@ data class DownloadHeaderItem(
 
     override fun equals(other: Any?): Boolean {
         if (this === other) return true
-        if (other is DownloadHeaderItem) {
-            return id == other.id && name == other.name
-        }
-        return false
+        if (javaClass != other?.javaClass) return false
+
+        other as DownloadHeaderItem
+
+        if (id != other.id) return false
+        if (name != other.name) return false
+        if (size != other.size) return false
+        if (subItemsCount != other.subItemsCount) return false
+        if (subItems !== other.subItems) return false
+
+        return true
     }
 
     override fun hashCode(): Int {
-        return id.hashCode()
+        var result = id.hashCode()
+        result = 31 * result + name.hashCode()
+        result = 31 * result + size
+        result = 31 * result + subItems.hashCode()
+        return result
     }
 
     init {

+ 30 - 25
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt

@@ -1,14 +1,21 @@
 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.data.download.model.DownloadQueue
 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.collect
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
 import logcat.LogPriority
-import rx.Observable
-import rx.android.schedulers.AndroidSchedulers
 import uy.kohesive.injekt.injectLazy
 
 /**
@@ -21,37 +28,34 @@ class DownloadPresenter : BasePresenter<DownloadController>() {
     /**
      * Property to get the queue from the download manager.
      */
-    val downloadQueue: DownloadQueue
+    private val downloadQueue: DownloadQueue
         get() = downloadManager.queue
 
+    private val _state = MutableStateFlow(emptyList<DownloadHeaderItem>())
+    val state = _state.asStateFlow()
+
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
 
-        downloadQueue.getUpdatedObservable()
-            .observeOn(AndroidSchedulers.mainThread())
-            .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) })
+        presenterScope.launch {
+            downloadQueue.getUpdatedAsFlow()
+                .catch { error -> logcat(LogPriority.ERROR, error) }
+                .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) })
+                            }
                         }
-                    }
-            }
-            .subscribeLatestCache(DownloadController::onNextDownloads) { _, error ->
-                logcat(LogPriority.ERROR, error)
-            }
+                }
+                .collect { newList -> _state.update { newList } }
+        }
     }
 
-    fun getDownloadStatusObservable(): Observable<Download> {
-        return downloadQueue.getStatusObservable()
-            .startWith(downloadQueue.getActiveDownloads())
-    }
+    fun getDownloadStatusFlow() = downloadQueue.getStatusAsFlow()
 
-    fun getDownloadProgressObservable(): Observable<Download> {
-        return downloadQueue.getProgressObservable()
-            .onBackpressureBuffer()
-    }
+    fun getDownloadProgressFlow() = downloadQueue.getProgressAsFlow()
 
     /**
      * Pauses the download queue.
@@ -63,7 +67,8 @@ class DownloadPresenter : BasePresenter<DownloadController>() {
     /**
      * Clears the download queue.
      */
-    fun clearQueue() {
+    fun clearQueue(context: Context) {
+        DownloadService.stop(context)
         downloadManager.clearQueue()
     }
 

+ 1 - 0
app/src/main/res/layout/download_header.xml

@@ -30,6 +30,7 @@
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_gravity="start"
+            android:layout_marginEnd="4dp"
             android:paddingHorizontal="10dp"
             android:paddingVertical="8dp"
             android:scaleType="center"

+ 1 - 0
app/src/main/res/layout/download_item.xml

@@ -87,6 +87,7 @@
             android:id="@+id/menu"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
+            android:layout_marginEnd="4dp"
             android:layout_toEndOf="@id/download_progress_text"
             android:background="?attr/selectableItemBackgroundBorderless"
             android:contentDescription="@string/action_menu"

+ 0 - 8
app/src/main/res/layout/download_controller.xml → app/src/main/res/layout/download_list.xml

@@ -11,7 +11,6 @@
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:clipToPadding="false"
-        android:paddingBottom="@dimen/fab_list_padding"
         tools:listitem="@layout/download_item" />
 
     <eu.kanade.tachiyomi.widget.MaterialFastScroll
@@ -22,11 +21,4 @@
         app:fastScrollerBubbleEnabled="false"
         tools:visibility="visible" />
 
-    <eu.kanade.tachiyomi.widget.EmptyView
-        android:id="@+id/empty_view"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:visibility="gone" />
-
 </FrameLayout>

+ 1 - 0
gradle/libs.versions.toml

@@ -62,6 +62,7 @@ flexible-adapter-ui = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui:c801
 photoview = "com.github.chrisbanes:PhotoView:2.3.0"
 directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
 insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
+cascade = "me.saket.cascade:cascade-compose:2.0.0-beta1"
 
 conductor-core = { module = "com.bluelinelabs:conductor", version.ref = "conductor_version" }
 conductor-support-preference = { module = "com.github.tachiyomiorg:conductor-support-preference", version.ref = "conductor_version" }