DownloadController.kt 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. package eu.kanade.tachiyomi.ui.download
  2. import android.view.LayoutInflater
  3. import android.view.Menu
  4. import android.view.MenuInflater
  5. import android.view.MenuItem
  6. import android.view.View
  7. import android.view.ViewGroup
  8. import androidx.core.view.isVisible
  9. import androidx.recyclerview.widget.LinearLayoutManager
  10. import androidx.recyclerview.widget.RecyclerView
  11. import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
  12. import eu.kanade.tachiyomi.R
  13. import eu.kanade.tachiyomi.data.download.DownloadService
  14. import eu.kanade.tachiyomi.data.download.model.Download
  15. import eu.kanade.tachiyomi.databinding.DownloadControllerBinding
  16. import eu.kanade.tachiyomi.source.model.Page
  17. import eu.kanade.tachiyomi.ui.base.controller.FabController
  18. import eu.kanade.tachiyomi.ui.base.controller.NucleusController
  19. import eu.kanade.tachiyomi.util.view.shrinkOnScroll
  20. import java.util.concurrent.TimeUnit
  21. import kotlinx.coroutines.flow.launchIn
  22. import kotlinx.coroutines.flow.onEach
  23. import reactivecircus.flowbinding.android.view.clicks
  24. import rx.Observable
  25. import rx.Subscription
  26. import rx.android.schedulers.AndroidSchedulers
  27. /**
  28. * Controller that shows the currently active downloads.
  29. * Uses R.layout.fragment_download_queue.
  30. */
  31. class DownloadController :
  32. NucleusController<DownloadControllerBinding, DownloadPresenter>(),
  33. FabController,
  34. DownloadAdapter.DownloadItemListener {
  35. /**
  36. * Adapter containing the active downloads.
  37. */
  38. private var adapter: DownloadAdapter? = null
  39. private var actionFab: ExtendedFloatingActionButton? = null
  40. private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
  41. /**
  42. * Map of subscriptions for active downloads.
  43. */
  44. private val progressSubscriptions by lazy { mutableMapOf<Download, Subscription>() }
  45. /**
  46. * Whether the download queue is running or not.
  47. */
  48. private var isRunning: Boolean = false
  49. init {
  50. setHasOptionsMenu(true)
  51. }
  52. override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
  53. binding = DownloadControllerBinding.inflate(inflater)
  54. return binding.root
  55. }
  56. override fun createPresenter(): DownloadPresenter {
  57. return DownloadPresenter()
  58. }
  59. override fun getTitle(): String? {
  60. return resources?.getString(R.string.label_download_queue)
  61. }
  62. override fun onViewCreated(view: View) {
  63. super.onViewCreated(view)
  64. // Check if download queue is empty and update information accordingly.
  65. setInformationView()
  66. // Initialize adapter.
  67. adapter = DownloadAdapter(this@DownloadController)
  68. binding.recycler.adapter = adapter
  69. adapter?.isHandleDragEnabled = true
  70. adapter?.fastScroller = binding.fastScroller
  71. // Set the layout manager for the recycler and fixed size.
  72. binding.recycler.layoutManager = LinearLayoutManager(view.context)
  73. binding.recycler.setHasFixedSize(true)
  74. actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
  75. // Subscribe to changes
  76. DownloadService.runningRelay
  77. .observeOn(AndroidSchedulers.mainThread())
  78. .subscribeUntilDestroy { onQueueStatusChange(it) }
  79. presenter.getDownloadStatusObservable()
  80. .observeOn(AndroidSchedulers.mainThread())
  81. .subscribeUntilDestroy { onStatusChange(it) }
  82. presenter.getDownloadProgressObservable()
  83. .observeOn(AndroidSchedulers.mainThread())
  84. .subscribeUntilDestroy { onUpdateDownloadedPages(it) }
  85. }
  86. override fun configureFab(fab: ExtendedFloatingActionButton) {
  87. actionFab = fab
  88. fab.clicks()
  89. .onEach {
  90. val context = applicationContext ?: return@onEach
  91. if (isRunning) {
  92. DownloadService.stop(context)
  93. presenter.pauseDownloads()
  94. } else {
  95. DownloadService.start(context)
  96. }
  97. setInformationView()
  98. }
  99. .launchIn(scope)
  100. }
  101. override fun cleanupFab(fab: ExtendedFloatingActionButton) {
  102. actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
  103. actionFab = null
  104. }
  105. override fun onDestroyView(view: View) {
  106. for (subscription in progressSubscriptions.values) {
  107. subscription.unsubscribe()
  108. }
  109. progressSubscriptions.clear()
  110. adapter = null
  111. super.onDestroyView(view)
  112. }
  113. override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
  114. inflater.inflate(R.menu.download_queue, menu)
  115. }
  116. override fun onPrepareOptionsMenu(menu: Menu) {
  117. menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
  118. menu.findItem(R.id.reorder).isVisible = !presenter.downloadQueue.isEmpty()
  119. }
  120. override fun onOptionsItemSelected(item: MenuItem): Boolean {
  121. val context = applicationContext ?: return false
  122. when (item.itemId) {
  123. R.id.clear_queue -> {
  124. DownloadService.stop(context)
  125. presenter.clearQueue()
  126. }
  127. R.id.newest, R.id.oldest -> {
  128. val adapter = adapter ?: return false
  129. val items = adapter.currentItems.sortedBy { it.download.chapter.date_upload }
  130. .toMutableList()
  131. if (item.itemId == R.id.newest) {
  132. items.reverse()
  133. }
  134. adapter.updateDataSet(items)
  135. val downloads = items.mapNotNull { it.download }
  136. presenter.reorder(downloads)
  137. }
  138. }
  139. return super.onOptionsItemSelected(item)
  140. }
  141. /**
  142. * Called when the status of a download changes.
  143. *
  144. * @param download the download whose status has changed.
  145. */
  146. private fun onStatusChange(download: Download) {
  147. when (download.status) {
  148. Download.DOWNLOADING -> {
  149. observeProgress(download)
  150. // Initial update of the downloaded pages
  151. onUpdateDownloadedPages(download)
  152. }
  153. Download.DOWNLOADED -> {
  154. unsubscribeProgress(download)
  155. onUpdateProgress(download)
  156. onUpdateDownloadedPages(download)
  157. }
  158. Download.ERROR -> unsubscribeProgress(download)
  159. }
  160. }
  161. /**
  162. * Observe the progress of a download and notify the view.
  163. *
  164. * @param download the download to observe its progress.
  165. */
  166. private fun observeProgress(download: Download) {
  167. val subscription = Observable.interval(50, TimeUnit.MILLISECONDS)
  168. // Get the sum of percentages for all the pages.
  169. .flatMap {
  170. Observable.from(download.pages)
  171. .map(Page::progress)
  172. .reduce { x, y -> x + y }
  173. }
  174. // Keep only the latest emission to avoid backpressure.
  175. .onBackpressureLatest()
  176. .observeOn(AndroidSchedulers.mainThread())
  177. .subscribe { progress ->
  178. // Update the view only if the progress has changed.
  179. if (download.totalProgress != progress) {
  180. download.totalProgress = progress
  181. onUpdateProgress(download)
  182. }
  183. }
  184. // Avoid leaking subscriptions
  185. progressSubscriptions.remove(download)?.unsubscribe()
  186. progressSubscriptions[download] = subscription
  187. }
  188. /**
  189. * Unsubscribes the given download from the progress subscriptions.
  190. *
  191. * @param download the download to unsubscribe.
  192. */
  193. private fun unsubscribeProgress(download: Download) {
  194. progressSubscriptions.remove(download)?.unsubscribe()
  195. }
  196. /**
  197. * Called when the queue's status has changed. Updates the visibility of the buttons.
  198. *
  199. * @param running whether the queue is now running or not.
  200. */
  201. private fun onQueueStatusChange(running: Boolean) {
  202. isRunning = running
  203. activity?.invalidateOptionsMenu()
  204. // Check if download queue is empty and update information accordingly.
  205. setInformationView()
  206. }
  207. /**
  208. * Called from the presenter to assign the downloads for the adapter.
  209. *
  210. * @param downloads the downloads from the queue.
  211. */
  212. fun onNextDownloads(downloads: List<DownloadItem>) {
  213. activity?.invalidateOptionsMenu()
  214. setInformationView()
  215. adapter?.updateDataSet(downloads)
  216. }
  217. /**
  218. * Called when the progress of a download changes.
  219. *
  220. * @param download the download whose progress has changed.
  221. */
  222. private fun onUpdateProgress(download: Download) {
  223. getHolder(download)?.notifyProgress()
  224. }
  225. /**
  226. * Called when a page of a download is downloaded.
  227. *
  228. * @param download the download whose page has been downloaded.
  229. */
  230. private fun onUpdateDownloadedPages(download: Download) {
  231. getHolder(download)?.notifyDownloadedPages()
  232. }
  233. /**
  234. * Returns the holder for the given download.
  235. *
  236. * @param download the download to find.
  237. * @return the holder of the download or null if it's not bound.
  238. */
  239. private fun getHolder(download: Download): DownloadHolder? {
  240. return binding.recycler.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
  241. }
  242. /**
  243. * Set information view when queue is empty
  244. */
  245. private fun setInformationView() {
  246. if (presenter.downloadQueue.isEmpty()) {
  247. binding.emptyView.show(R.string.information_no_downloads)
  248. actionFab?.isVisible = false
  249. } else {
  250. binding.emptyView.hide()
  251. actionFab?.apply {
  252. isVisible = true
  253. setText(
  254. if (isRunning) {
  255. R.string.action_pause
  256. } else {
  257. R.string.action_resume
  258. }
  259. )
  260. setIconResource(
  261. if (isRunning) {
  262. R.drawable.ic_pause_24dp
  263. } else {
  264. R.drawable.ic_play_arrow_24dp
  265. }
  266. )
  267. }
  268. }
  269. }
  270. /**
  271. * Called when an item is released from a drag.
  272. *
  273. * @param position The position of the released item.
  274. */
  275. override fun onItemReleased(position: Int) {
  276. val adapter = adapter ?: return
  277. val downloads = (0 until adapter.itemCount).mapNotNull { adapter.getItem(it)?.download }
  278. presenter.reorder(downloads)
  279. }
  280. /**
  281. * Called when the menu item of a download is pressed
  282. *
  283. * @param position The position of the item
  284. * @param menuItem The menu Item pressed
  285. */
  286. override fun onMenuItemClick(position: Int, menuItem: MenuItem) {
  287. when (menuItem.itemId) {
  288. R.id.move_to_top, R.id.move_to_bottom -> {
  289. val download = adapter?.getItem(position) ?: return
  290. val items = adapter?.currentItems?.toMutableList() ?: return
  291. items.remove(download)
  292. if (menuItem.itemId == R.id.move_to_top) {
  293. items.add(0, download)
  294. } else {
  295. items.add(download)
  296. }
  297. val adapter = adapter ?: return
  298. adapter.updateDataSet(items)
  299. val downloads = adapter.currentItems.mapNotNull { it?.download }
  300. presenter.reorder(downloads)
  301. }
  302. R.id.cancel_download -> {
  303. val download = adapter?.getItem(position)?.download ?: return
  304. presenter.cancelDownload(download)
  305. val adapter = adapter ?: return
  306. adapter.removeItem(position)
  307. val downloads = adapter.currentItems.mapNotNull { it?.download }
  308. presenter.reorder(downloads)
  309. }
  310. }
  311. }
  312. }