Selaa lähdekoodia

Add download queue features from J2K fork

arkon 5 vuotta sitten
vanhempi
commit
fb897e37d1

+ 34 - 1
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt

@@ -5,6 +5,7 @@ import com.hippo.unifile.UniFile
 import com.jakewharton.rxrelay.BehaviorRelay
 import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.download.model.Download
 import eu.kanade.tachiyomi.data.download.model.DownloadQueue
 import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.source.SourceManager
@@ -19,7 +20,7 @@ import uy.kohesive.injekt.injectLazy
  *
  * @param context the application context.
  */
-class DownloadManager(context: Context) {
+class DownloadManager(private val context: Context) {
 
     /**
      * The sources manager.
@@ -92,6 +93,29 @@ class DownloadManager(context: Context) {
         downloader.clearQueue(isNotification)
     }
 
+    /**
+     * Reorders the download queue.
+     *
+     * @param downloads value to set the download queue to
+     */
+    fun reorderQueue(downloads: List<Download>) {
+        val wasRunning = downloader.isRunning
+
+        if (downloads.isEmpty()) {
+            DownloadService.stop(context)
+            downloader.queue.clear()
+            return
+        }
+
+        downloader.pause()
+        downloader.queue.clear()
+        downloader.queue.addAll(downloads)
+
+        if (wasRunning) {
+            downloader.start()
+        }
+    }
+
     /**
      * Tells the downloader to enqueue the given list of chapters.
      *
@@ -157,6 +181,15 @@ class DownloadManager(context: Context) {
         return cache.getDownloadCount(manga)
     }
 
+    /**
+     * Calls delete chapter, which deletes a temp download.
+     *
+     * @param download the download to cancel.
+     */
+    fun deletePendingDownload(download: Download) {
+        deleteChapters(listOf(download.chapter), download.manga, download.source)
+    }
+
     /**
      * Deletes the directories of a list of downloaded chapters.
      *

+ 2 - 1
app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt

@@ -83,7 +83,8 @@ class Downloader(
      * Whether the downloader is running.
      */
     @Volatile
-    private var isRunning: Boolean = false
+    var isRunning: Boolean = false
+        private set
 
     init {
         launchNow {

+ 14 - 1
app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt

@@ -24,17 +24,30 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
         set(status) {
             field = status
             statusSubject?.onNext(this)
+            statusCallback?.invoke(this)
         }
 
     @Transient
     private var statusSubject: PublishSubject<Download>? = null
 
+    @Transient
+    private var statusCallback: ((Download) -> Unit)? = null
+
+    val progress: Int
+        get() {
+            val pages = pages ?: return 0
+            return pages.map(Page::progress).average().toInt()
+        }
+
     fun setStatusSubject(subject: PublishSubject<Download>?) {
         statusSubject = subject
     }
 
-    companion object {
+    fun setStatusCallback(f: ((Download) -> Unit)?) {
+        statusCallback = f
+    }
 
+    companion object {
         const val NOT_DOWNLOADED = 0
         const val QUEUE = 1
         const val DOWNLOADING = 2

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

@@ -12,16 +12,18 @@ import rx.subjects.PublishSubject
 class DownloadQueue(
     private val store: DownloadStore,
     private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>()
-) :
-    List<Download> by queue {
+) : List<Download> by queue {
 
     private val statusSubject = PublishSubject.create<Download>()
 
     private val updatedRelay = PublishRelay.create<Unit>()
 
+    private val downloadListeners = mutableListOf<DownloadListener>()
+
     fun addAll(downloads: List<Download>) {
         downloads.forEach { download ->
             download.setStatusSubject(statusSubject)
+            download.setStatusCallback(::setPagesFor)
             download.status = Download.QUEUE
         }
         queue.addAll(downloads)
@@ -33,6 +35,11 @@ class DownloadQueue(
         val removed = queue.remove(download)
         store.remove(download)
         download.setStatusSubject(null)
+        download.setStatusCallback(null)
+        if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) {
+            download.status = Download.NOT_DOWNLOADED
+        }
+        callListeners(download)
         if (removed) {
             updatedRelay.call(Unit)
         }
@@ -55,6 +62,11 @@ class DownloadQueue(
     fun clear() {
         queue.forEach { download ->
             download.setStatusSubject(null)
+            download.setStatusCallback(null)
+            if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) {
+                download.status = Download.NOT_DOWNLOADED
+            }
+            callListeners(download)
         }
         queue.clear()
         store.clear()
@@ -70,6 +82,24 @@ class DownloadQueue(
             .startWith(Unit)
             .map { this }
 
+    private fun setPagesFor(download: Download) {
+        if (download.status == Download.DOWNLOADING) {
+            download.pages?.forEach { page ->
+                page.setStatusCallback {
+                    callListeners(download)
+                }
+            }
+        } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) {
+            setPagesSubject(download.pages, null)
+        }
+
+        callListeners(download)
+    }
+
+    private fun callListeners(download: Download) {
+        downloadListeners.forEach { it.updateDownload(download) }
+    }
+
     fun getProgressObservable(): Observable<Download> {
         return statusSubject.onBackpressureBuffer()
                 .startWith(getActiveDownloads())
@@ -77,12 +107,14 @@ class DownloadQueue(
                     if (download.status == Download.DOWNLOADING) {
                         val pageStatusSubject = PublishSubject.create<Int>()
                         setPagesSubject(download.pages, pageStatusSubject)
+                        callListeners(download)
                         return@flatMap pageStatusSubject
                                 .onBackpressureBuffer()
                                 .filter { it == Page.READY }
                                 .map { download }
                     } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) {
                         setPagesSubject(download.pages, null)
+                        callListeners(download)
                     }
                     Observable.just(download)
                 }
@@ -96,4 +128,16 @@ class DownloadQueue(
             }
         }
     }
+
+    fun addListener(listener: DownloadListener) {
+        downloadListeners.add(listener)
+    }
+
+    fun removeListener(listener: DownloadListener) {
+        downloadListeners.remove(listener)
+    }
+
+    interface DownloadListener {
+        fun updateDownload(download: Download)
+    }
 }

+ 12 - 0
app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt

@@ -20,15 +20,23 @@ open class Page(
         set(value) {
             field = value
             statusSubject?.onNext(value)
+            statusCallback?.invoke(this)
         }
 
     @Transient
     @Volatile
     var progress: Int = 0
+        set(value) {
+            field = value
+            statusCallback?.invoke(this)
+        }
 
     @Transient
     private var statusSubject: Subject<Int, Int>? = null
 
+    @Transient
+    private var statusCallback: ((Page) -> Unit)? = null
+
     override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
         progress = if (contentLength > 0) {
             (100 * bytesRead / contentLength).toInt()
@@ -41,6 +49,10 @@ open class Page(
         this.statusSubject = subject
     }
 
+    fun setStatusCallback(f: ((Page) -> Unit)?) {
+        statusCallback = f
+    }
+
     companion object {
         const val QUEUE = 0
         const val LOAD_PAGE = 1

+ 12 - 57
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt

@@ -1,71 +1,26 @@
 package eu.kanade.tachiyomi.ui.download
 
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.download.model.Download
-import eu.kanade.tachiyomi.util.view.inflate
+import android.view.MenuItem
+import eu.davidea.flexibleadapter.FlexibleAdapter
 
 /**
  * Adapter storing a list of downloads.
  *
  * @param context the context of the fragment containing this adapter.
  */
-class DownloadAdapter : RecyclerView.Adapter<DownloadHolder>() {
-
-    private var items = emptyList<Download>()
-
-    init {
-        setHasStableIds(true)
-    }
-
-    /**
-     * Sets a list of downloads in the adapter.
-     *
-     * @param downloads the list to set.
-     */
-    fun setItems(downloads: List<Download>) {
-        items = downloads
-        notifyDataSetChanged()
-    }
-
-    /**
-     * Returns the number of downloads in the adapter
-     */
-    override fun getItemCount(): Int {
-        return items.size
-    }
+class DownloadAdapter(controller: DownloadController) : FlexibleAdapter<DownloadItem>(
+    null,
+    controller,
+    true
+) {
 
     /**
-     * Returns the identifier for a download.
-     *
-     * @param position the position in the adapter.
-     * @return an identifier for the item.
+     * Listener called when an item of the list is released.
      */
-    override fun getItemId(position: Int): Long {
-        return items[position].chapter.id!!
-    }
+    val downloadItemListener: DownloadItemListener = controller
 
-    /**
-     * Creates a new view holder.
-     *
-     * @param parent the parent view.
-     * @param viewType the type of the holder.
-     * @return a new view holder for a manga.
-     */
-    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadHolder {
-        val view = parent.inflate(R.layout.download_item)
-        return DownloadHolder(view)
-    }
-
-    /**
-     * Binds a holder with a new position.
-     *
-     * @param holder the holder to bind.
-     * @param position the position to bind.
-     */
-    override fun onBindViewHolder(holder: DownloadHolder, position: Int) {
-        val download = items[position]
-        holder.onSetValues(download)
+    interface DownloadItemListener {
+        fun onItemReleased(position: Int)
+        fun onMenuItemClick(position: Int, menuItem: MenuItem)
     }
 }

+ 65 - 13
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt

@@ -24,7 +24,8 @@ import rx.android.schedulers.AndroidSchedulers
  * Controller that shows the currently active downloads.
  * Uses R.layout.fragment_download_queue.
  */
-class DownloadController : NucleusController<DownloadPresenter>() {
+class DownloadController : NucleusController<DownloadPresenter>(),
+    DownloadAdapter.DownloadItemListener {
 
     /**
      * Adapter containing the active downloads.
@@ -64,14 +65,15 @@ class DownloadController : NucleusController<DownloadPresenter>() {
         setInformationView()
 
         // Initialize adapter.
-        adapter = DownloadAdapter()
+        adapter = DownloadAdapter(this@DownloadController)
         recycler.adapter = adapter
+        adapter?.isHandleDragEnabled = true
 
         // Set the layout manager for the recycler and fixed size.
         recycler.layoutManager = LinearLayoutManager(view.context)
         recycler.setHasFixedSize(true)
 
-        // Suscribe to changes
+        // Subscribe to changes
         DownloadService.runningRelay
                 .observeOn(AndroidSchedulers.mainThread())
                 .subscribeUntilDestroy { onQueueStatusChange(it) }
@@ -99,14 +101,10 @@ class DownloadController : NucleusController<DownloadPresenter>() {
     }
 
     override fun onPrepareOptionsMenu(menu: Menu) {
-        // Set start button visibility.
         menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
-
-        // Set pause button visibility.
         menu.findItem(R.id.pause_queue).isVisible = isRunning
-
-        // Set clear button visibility.
         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 {
@@ -121,6 +119,16 @@ class DownloadController : NucleusController<DownloadPresenter>() {
                 DownloadService.stop(context)
                 presenter.clearQueue()
             }
+            R.id.newest, R.id.oldest -> {
+                val adapter = adapter ?: return false
+                val items = adapter.currentItems.sortedBy { it.download.chapter.date_upload }
+                    .toMutableList()
+                if (item.itemId == R.id.newest)
+                    items.reverse()
+                adapter.updateDataSet(items)
+                val downloads = items.mapNotNull { it.download }
+                presenter.reorder(downloads)
+            }
         }
         return super.onOptionsItemSelected(item)
     }
@@ -173,7 +181,7 @@ class DownloadController : NucleusController<DownloadPresenter>() {
         // Avoid leaking subscriptions
         progressSubscriptions.remove(download)?.unsubscribe()
 
-        progressSubscriptions.put(download, subscription)
+        progressSubscriptions[download] = subscription
     }
 
     /**
@@ -203,10 +211,10 @@ class DownloadController : NucleusController<DownloadPresenter>() {
      *
      * @param downloads the downloads from the queue.
      */
-    fun onNextDownloads(downloads: List<Download>) {
+    fun onNextDownloads(downloads: List<DownloadItem>) {
         activity?.invalidateOptionsMenu()
         setInformationView()
-        adapter?.setItems(downloads)
+        adapter?.updateDataSet(downloads)
     }
 
     /**
@@ -214,7 +222,7 @@ class DownloadController : NucleusController<DownloadPresenter>() {
      *
      * @param download the download whose progress has changed.
      */
-    fun onUpdateProgress(download: Download) {
+    private fun onUpdateProgress(download: Download) {
         getHolder(download)?.notifyProgress()
     }
 
@@ -223,7 +231,7 @@ class DownloadController : NucleusController<DownloadPresenter>() {
      *
      * @param download the download whose page has been downloaded.
      */
-    fun onUpdateDownloadedPages(download: Download) {
+    private fun onUpdateDownloadedPages(download: Download) {
         getHolder(download)?.notifyDownloadedPages()
     }
 
@@ -247,4 +255,48 @@ class DownloadController : NucleusController<DownloadPresenter>() {
             empty_view?.hide()
         }
     }
+
+    /**
+     * 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 = (0 until adapter.itemCount).mapNotNull { adapter.getItem(it)?.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) {
+        when (menuItem.itemId) {
+            R.id.move_to_top, R.id.move_to_bottom -> {
+                val items = adapter?.currentItems?.toMutableList() ?: return
+                val item = items[position]
+                items.remove(item)
+                if (menuItem.itemId == R.id.move_to_top)
+                    items.add(0, item)
+                else
+                    items.add(item)
+                adapter?.updateDataSet(items)
+                val downloads = items.mapNotNull { it.download }
+                presenter.reorder(downloads)
+            }
+            R.id.cancel_download -> {
+                val download = adapter?.getItem(position)?.download ?: return
+                presenter.cancelDownload(download)
+
+                adapter?.removeItem(position)
+                val adapter = adapter ?: return
+                val downloads =
+                    (0 until adapter.itemCount).mapNotNull { adapter.getItem(it)?.download }
+                presenter.reorder(downloads)
+            }
+        }
+    }
 }

+ 45 - 21
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt

@@ -1,12 +1,16 @@
 package eu.kanade.tachiyomi.ui.download
 
 import android.view.View
+import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.download.model.Download
-import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
-import kotlinx.android.synthetic.main.download_item.view.chapter_title
-import kotlinx.android.synthetic.main.download_item.view.download_progress
-import kotlinx.android.synthetic.main.download_item.view.download_progress_text
-import kotlinx.android.synthetic.main.download_item.view.manga_title
+import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
+import eu.kanade.tachiyomi.util.view.popupMenu
+import kotlinx.android.synthetic.main.download_item.chapter_title
+import kotlinx.android.synthetic.main.download_item.download_progress
+import kotlinx.android.synthetic.main.download_item.download_progress_text
+import kotlinx.android.synthetic.main.download_item.manga_full_title
+import kotlinx.android.synthetic.main.download_item.menu
+import kotlinx.android.synthetic.main.download_item.reorder
 
 /**
  * Class used to hold the data of a download.
@@ -15,33 +19,37 @@ import kotlinx.android.synthetic.main.download_item.view.manga_title
  * @param view the inflated view for this holder.
  * @constructor creates a new download holder.
  */
-class DownloadHolder(private val view: View) : BaseViewHolder(view) {
+class DownloadHolder(private val view: View, val adapter: DownloadAdapter) :
+    BaseFlexibleViewHolder(view, adapter) {
+
+    init {
+        setDragHandleView(reorder)
+        menu.setOnClickListener { it.post { showPopupMenu(it) } }
+    }
 
     private lateinit var download: Download
 
     /**
-     * Method called from [DownloadAdapter.onBindViewHolder]. It updates the data for this
-     * holder with the given download.
+     * Binds this holder with the given category.
      *
-     * @param download the download to bind.
+     * @param category The category to bind.
      */
-    fun onSetValues(download: Download) {
+    fun bind(download: Download) {
         this.download = download
-
         // Update the chapter name.
-        view.chapter_title.text = download.chapter.name
+        chapter_title.text = download.chapter.name
 
         // Update the manga title
-        view.manga_title.text = download.manga.title
+        manga_full_title.text = download.manga.title
 
         // Update the progress bar and the number of downloaded pages
         val pages = download.pages
         if (pages == null) {
-            view.download_progress.progress = 0
-            view.download_progress.max = 1
-            view.download_progress_text.text = ""
+            download_progress.progress = 0
+            download_progress.max = 1
+            download_progress_text.text = ""
         } else {
-            view.download_progress.max = pages.size * 100
+            download_progress.max = pages.size * 100
             notifyProgress()
             notifyDownloadedPages()
         }
@@ -52,10 +60,10 @@ class DownloadHolder(private val view: View) : BaseViewHolder(view) {
      */
     fun notifyProgress() {
         val pages = download.pages ?: return
-        if (view.download_progress.max == 1) {
-            view.download_progress.max = pages.size * 100
+        if (download_progress.max == 1) {
+            download_progress.max = pages.size * 100
         }
-        view.download_progress.progress = download.totalProgress
+        download_progress.progress = download.totalProgress
     }
 
     /**
@@ -63,6 +71,22 @@ class DownloadHolder(private val view: View) : BaseViewHolder(view) {
      */
     fun notifyDownloadedPages() {
         val pages = download.pages ?: return
-        view.download_progress_text.text = "${download.downloadedImages}/${pages.size}"
+        download_progress_text.text = "${download.downloadedImages}/${pages.size}"
+    }
+
+    override fun onItemReleased(position: Int) {
+        super.onItemReleased(position)
+        adapter.downloadItemListener.onItemReleased(position)
+    }
+
+    private fun showPopupMenu(view: View) {
+        view.popupMenu(R.menu.download_single, {
+            findItem(R.id.move_to_top).isVisible = adapterPosition != 0
+            findItem(R.id.move_to_bottom).isVisible =
+                adapterPosition != adapter.itemCount - 1
+        }, {
+            adapter.downloadItemListener.onMenuItemClick(adapterPosition, this)
+            true
+        })
     }
 }

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

@@ -0,0 +1,65 @@
+package eu.kanade.tachiyomi.ui.download
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
+import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.download.model.Download
+
+class DownloadItem(val download: Download) : AbstractFlexibleItem<DownloadHolder>() {
+
+    override fun getLayoutRes(): Int {
+        return R.layout.download_item
+    }
+
+    /**
+     * Returns a new view holder for this item.
+     *
+     * @param view The view of this item.
+     * @param adapter The adapter of this item.
+     */
+    override fun createViewHolder(
+        view: View,
+        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>
+    ): DownloadHolder {
+        return DownloadHolder(view, adapter as DownloadAdapter)
+    }
+
+    /**
+     * Binds the given view holder with this item.
+     *
+     * @param adapter The adapter of this item.
+     * @param holder The holder to bind.
+     * @param position The position of this item in the adapter.
+     * @param payloads List of partial changes.
+     */
+    override fun bindViewHolder(
+        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
+        holder: DownloadHolder,
+        position: Int,
+        payloads: MutableList<Any>
+    ) {
+        holder.bind(download)
+    }
+
+    /**
+     * Returns true if this item is draggable.
+     */
+    override fun isDraggable(): Boolean {
+        return true
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other is DownloadItem) {
+            return download.chapter.id == other.download.chapter.id
+        }
+        return false
+    }
+
+    override fun hashCode(): Int {
+        return download.chapter.id!!.toInt()
+    }
+}

+ 9 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt

@@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
 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 java.util.ArrayList
 import rx.Observable
 import rx.android.schedulers.AndroidSchedulers
 import timber.log.Timber
@@ -16,9 +15,6 @@ import uy.kohesive.injekt.injectLazy
  */
 class DownloadPresenter : BasePresenter<DownloadController>() {
 
-    /**
-     * Download manager.
-     */
     val downloadManager: DownloadManager by injectLazy()
 
     /**
@@ -32,7 +28,7 @@ class DownloadPresenter : BasePresenter<DownloadController>() {
 
         downloadQueue.getUpdatedObservable()
                 .observeOn(AndroidSchedulers.mainThread())
-                .map { ArrayList(it) }
+                .map { it.map(::DownloadItem) }
                 .subscribeLatestCache(DownloadController::onNextDownloads) { _, error ->
                     Timber.e(error)
                 }
@@ -61,4 +57,12 @@ class DownloadPresenter : BasePresenter<DownloadController>() {
     fun clearQueue() {
         downloadManager.clearQueue()
     }
+
+    fun reorder(downloads: List<Download>) {
+        downloadManager.reorderQueue(downloads)
+    }
+
+    fun cancelDownload(download: Download) {
+        downloadManager.deletePendingDownload(download)
+    }
 }

+ 25 - 0
app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt

@@ -5,11 +5,17 @@ package eu.kanade.tachiyomi.util.view
 import android.graphics.Color
 import android.graphics.Point
 import android.graphics.Typeface
+import android.view.Gravity
+import android.view.Menu
+import android.view.MenuItem
 import android.view.View
 import android.widget.TextView
+import androidx.annotation.MenuRes
+import androidx.appcompat.widget.PopupMenu
 import com.amulyakhare.textdrawable.TextDrawable
 import com.amulyakhare.textdrawable.util.ColorGenerator
 import com.google.android.material.snackbar.Snackbar
+import eu.kanade.tachiyomi.R
 import kotlin.math.min
 
 /**
@@ -36,6 +42,25 @@ inline fun View.snack(message: String, length: Int = Snackbar.LENGTH_LONG, f: Sn
     return snack
 }
 
+/**
+ * Shows a popup menu on top of this view.
+ *
+ * @param menuRes menu items to inflate the menu with.
+ * @param initMenu function to execute when the menu after is inflated.
+ * @param onMenuItemClick function to execute when a menu item is clicked.
+ */
+fun View.popupMenu(@MenuRes menuRes: Int, initMenu: (Menu.() -> Unit)? = null, onMenuItemClick: MenuItem.() -> Boolean) {
+    val popup = PopupMenu(context, this, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0)
+    popup.menuInflater.inflate(menuRes, popup.menu)
+
+    if (initMenu != null) {
+        popup.menu.initMenu()
+    }
+    popup.setOnMenuItemClickListener { it.onMenuItemClick() }
+
+    popup.show()
+}
+
 inline fun View.visible() {
     visibility = View.VISIBLE
 }

+ 9 - 0
app/src/main/res/drawable/ic_more_vert_24dp.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:fillColor="#FF000000"
+      android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
+</vector>

+ 63 - 21
app/src/main/res/layout/download_item.xml

@@ -1,47 +1,89 @@
 <?xml version="1.0" encoding="utf-8"?>
-<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
-    android:paddingStart="@dimen/material_layout_keylines_screen_edge_margin"
-    android:paddingTop="@dimen/material_component_lists_padding_above_list"
-    android:paddingEnd="@dimen/material_layout_keylines_screen_edge_margin">
+    android:paddingStart="0dp"
+    android:paddingTop="@dimen/material_component_lists_padding_above_list">
 
-    <TextView
-        android:id="@+id/download_progress_text"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_alignParentEnd="true"
-        android:maxLines="1"
-        android:textAppearance="@style/TextAppearance.Regular.Caption.Hint"
-        tools:text="(0/10)" />
+    <ImageView
+        android:id="@+id/reorder"
+        android:layout_width="@dimen/material_component_lists_single_line_with_avatar_height"
+        android:layout_height="0dp"
+        android:layout_alignParentStart="true"
+        android:layout_gravity="start"
+        android:scaleType="center"
+        android:tint="?android:attr/textColorPrimary"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:srcCompat="@drawable/ic_reorder_grey_24dp" />
 
     <TextView
-        android:id="@+id/manga_title"
-        android:layout_width="match_parent"
+        android:id="@+id/manga_full_title"
+        android:layout_width="0dp"
         android:layout_height="wrap_content"
-        android:layout_alignParentStart="true"
-        android:layout_toStartOf="@id/download_progress_text"
+        android:layout_marginEnd="8dp"
+        android:layout_toEndOf="@id/reorder"
         android:ellipsize="end"
         android:maxLines="1"
         android:textAppearance="@style/TextAppearance.Regular.Body1"
+        app:layout_constraintEnd_toStartOf="@+id/download_progress_text"
+        app:layout_constraintStart_toEndOf="@+id/reorder"
+        app:layout_constraintTop_toTopOf="parent"
         tools:text="Manga title" />
 
     <TextView
         android:id="@+id/chapter_title"
-        android:layout_width="wrap_content"
+        android:layout_width="0dp"
         android:layout_height="wrap_content"
-        android:layout_below="@id/manga_title"
+        android:layout_marginTop="4dp"
+        android:layout_toEndOf="@id/reorder"
         android:ellipsize="end"
         android:maxLines="1"
         android:textAppearance="@style/TextAppearance.Regular.Caption"
+        app:layout_constraintEnd_toStartOf="@+id/menu"
+        app:layout_constraintStart_toStartOf="@+id/manga_full_title"
+        app:layout_constraintTop_toBottomOf="@+id/manga_full_title"
         tools:text="Chapter Title" />
 
     <ProgressBar
         android:id="@+id/download_progress"
         style="?android:attr/progressBarStyleHorizontal"
-        android:layout_width="match_parent"
+        android:layout_width="0dp"
         android:layout_height="wrap_content"
-        android:layout_below="@id/chapter_title" />
+        android:layout_marginBottom="8dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toStartOf="@+id/menu"
+        app:layout_constraintStart_toEndOf="@+id/reorder"
+        app:layout_constraintTop_toBottomOf="@+id/chapter_title" />
+
+    <TextView
+        android:id="@+id/download_progress_text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_toEndOf="@id/manga_full_title"
+        android:maxLines="1"
+        android:textAppearance="@style/TextAppearance.Regular.Caption.Hint"
+        app:layout_constraintBottom_toBottomOf="@+id/manga_full_title"
+        app:layout_constraintEnd_toStartOf="@+id/menu"
+        app:layout_constraintTop_toTopOf="@+id/manga_full_title"
+        tools:text="(0/10)" />
+
+    <ImageButton
+        android:id="@+id/menu"
+        android:layout_width="44dp"
+        android:layout_height="@dimen/material_component_lists_single_line_with_avatar_height"
+        android:layout_toEndOf="@id/download_progress_text"
+        android:background="?selectableItemBackgroundBorderless"
+        android:contentDescription="@string/action_menu"
+        android:paddingStart="10dp"
+        android:paddingEnd="10dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:srcCompat="@drawable/ic_more_vert_24dp"
+        app:tint="?attr/colorOnBackground" />
 
-</RelativeLayout>
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 14 - 3
app/src/main/res/menu/download_queue.xml

@@ -6,7 +6,6 @@
         android:id="@+id/start_queue"
         android:icon="@drawable/ic_play_arrow_24dp"
         android:title="@string/action_start"
-        android:visible="false"
         app:iconTint="?attr/colorOnPrimary"
         app:showAsAction="ifRoom" />
 
@@ -14,14 +13,26 @@
         android:id="@+id/pause_queue"
         android:icon="@drawable/ic_pause_24dp"
         android:title="@string/action_pause"
-        android:visible="false"
         app:iconTint="?attr/colorOnPrimary"
         app:showAsAction="ifRoom" />
 
+    <item
+        android:id="@+id/reorder"
+        android:title="@string/action_reorganize_by"
+        app:showAsAction="never">
+        <menu>
+            <item
+                android:id="@+id/newest"
+                android:title="@string/action_newest" />
+            <item
+                android:id="@+id/oldest"
+                android:title="@string/action_oldest" />
+        </menu>
+    </item>
+
     <item
         android:id="@+id/clear_queue"
         android:title="@string/action_cancel_all"
-        android:visible="false"
         app:showAsAction="never" />
 
 </menu>

+ 16 - 0
app/src/main/res/menu/download_single.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <item
+        android:id="@+id/move_to_top"
+        android:title="@string/action_move_to_top" />
+
+    <item
+        android:id="@+id/move_to_bottom"
+        android:title="@string/action_move_to_bottom" />
+
+    <item
+        android:id="@+id/cancel_download"
+        android:title="@string/action_cancel" />
+
+</menu>

+ 6 - 0
app/src/main/res/values/strings.xml

@@ -29,6 +29,7 @@
 
     <!-- Actions -->
     <string name="action_settings">Settings</string>
+    <string name="action_menu">Menu</string>
     <string name="action_filter">Filter</string>
     <string name="action_filter_downloaded">Downloaded</string>
     <string name="action_filter_bookmarked">Bookmarked</string>
@@ -87,6 +88,11 @@
     <string name="action_cancel">Cancel</string>
     <string name="action_cancel_all">Cancel all</string>
     <string name="action_sort">Sort</string>
+    <string name="action_reorganize_by">Reorder</string>
+    <string name="action_newest">Newest</string>
+    <string name="action_oldest">Oldest</string>
+    <string name="action_move_to_top">Move to top</string>
+    <string name="action_move_to_bottom">Move to bottom</string>
     <string name="action_install">Install</string>
     <string name="action_share">Share</string>
     <string name="action_save">Save</string>