Better handling of download progress and event. Added an option to retry failed downloads. Performance improvement around downloads UI.

This commit is contained in:
Antoine POPINEAU 2020-06-14 14:59:50 +02:00
parent 94fd3d51aa
commit a2c35595c7
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
11 changed files with 187 additions and 100 deletions

View File

@ -111,6 +111,7 @@ dependencies {
implementation("com.android.support.constraint:constraint-layout:1.1.3")
implementation("com.google.android.exoplayer:exoplayer-core:2.11.5")
implementation("com.google.android.exoplayer:exoplayer-ui:2.11.5")
implementation("com.google.android.exoplayer:extension-mediasession:2.11.5")
implementation("com.aliassadi:power-preference-lib:1.4.1")

View File

@ -2,11 +2,16 @@ package com.github.apognu.otter
import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.github.apognu.otter.playback.QueueManager
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.Request
import com.google.android.exoplayer2.database.ExoDatabaseProvider
import com.google.android.exoplayer2.offline.DefaultDownloadIndex
import com.google.android.exoplayer2.offline.DefaultDownloaderFactory
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.offline.DownloaderConstructorHelper
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.preference.PowerPreference
@ -30,8 +35,21 @@ class Otter : Application() {
val requestBus: BroadcastChannel<Request> = BroadcastChannel(10)
val progressBus: BroadcastChannel<Triple<Int, Int, Int>> = ConflatedBroadcastChannel()
var exoCache: SimpleCache? = null
var exoDatabase: ExoDatabaseProvider? = null
private val exoDatabase: ExoDatabaseProvider by lazy { ExoDatabaseProvider(this) }
val exoCache: SimpleCache by lazy {
PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().let {
SimpleCache(
cacheDir.resolve("media"),
LeastRecentlyUsedCacheEvictor(it * 1024 * 1024 * 1024),
exoDatabase
)
}
}
val exoDownloadManager: DownloadManager by lazy {
DownloaderConstructorHelper(exoCache, QueueManager.factory(this)).run {
DownloadManager(this@Otter, DefaultDownloadIndex(exoDatabase), DefaultDownloaderFactory(this))
}
}
override fun onCreate() {
super.onCreate()
@ -41,15 +59,6 @@ class Otter : Application() {
Thread.setDefaultUncaughtExceptionHandler(CrashReportHandler())
instance = this
exoDatabase = ExoDatabaseProvider(this)
PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().also {
exoCache = SimpleCache(
cacheDir.resolve("media"),
LeastRecentlyUsedCacheEvictor(it * 1024 * 1024 * 1024),
exoDatabase
)
}
when (PowerPreference.getDefaultFile().getString("night_mode")) {
"on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)

View File

@ -1,17 +1,19 @@
package com.github.apognu.otter.activities
import android.os.Bundle
import kotlinx.coroutines.flow.collect
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.DownloadsAdapter
import com.github.apognu.otter.utils.*
import com.google.gson.Gson
import com.google.android.exoplayer2.offline.Download
import kotlinx.android.synthetic.main.activity_downloads.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DownloadsActivity : AppCompatActivity() {
lateinit var adapter: DownloadsAdapter
@ -21,17 +23,24 @@ class DownloadsActivity : AppCompatActivity() {
setContentView(R.layout.activity_downloads)
adapter = DownloadsAdapter(this, RefreshListener()).also {
adapter = DownloadsAdapter(this, DownloadChangedListener()).also {
downloads.layoutManager = LinearLayoutManager(this)
downloads.adapter = it
}
}
GlobalScope.launch(Main) {
while (true) {
refresh()
delay(1000)
override fun onResume() {
super.onResume()
GlobalScope.launch(IO) {
EventBus.get().collect { event ->
if (event is Event.DownloadChanged) {
refreshTrack(event.download)
}
}
}
refresh()
}
private fun refresh() {
@ -42,7 +51,7 @@ class DownloadsActivity : AppCompatActivity() {
while (response.cursor.moveToNext()) {
val download = response.cursor.download
Gson().fromJson(String(download.request.data), DownloadInfo::class.java)?.let { info ->
download.getMetadata()?.let { info ->
adapter.downloads.add(info.apply {
this.download = download
})
@ -54,9 +63,26 @@ class DownloadsActivity : AppCompatActivity() {
}
}
inner class RefreshListener : DownloadsAdapter.OnRefreshListener {
override fun refresh() {
this@DownloadsActivity.refresh()
private suspend fun refreshTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
adapter.downloads.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
withContext(Main) {
adapter.downloads[match.second] = info.apply {
this.download = download
}
adapter.notifyItemChanged(match.second)
}
}
}
}
}
inner class DownloadChangedListener : DownloadsAdapter.OnDownloadChangedListener {
override fun onItemRemoved(index: Int) {
adapter.downloads.removeAt(index)
adapter.notifyDataSetChanged()
}
}
}

View File

@ -8,14 +8,14 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.playback.PinService
import com.github.apognu.otter.utils.DownloadInfo
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadService
import kotlinx.android.synthetic.main.row_download.view.*
class DownloadsAdapter(private val context: Context, private val listener: OnRefreshListener) : RecyclerView.Adapter<DownloadsAdapter.ViewHolder>() {
interface OnRefreshListener {
fun refresh()
class DownloadsAdapter(private val context: Context, private val listener: OnDownloadChangedListener) : RecyclerView.Adapter<DownloadsAdapter.ViewHolder>() {
interface OnDownloadChangedListener {
fun onItemRemoved(index: Int)
}
var downloads: MutableList<DownloadInfo> = mutableListOf()
@ -38,7 +38,15 @@ class DownloadsAdapter(private val context: Context, private val listener: OnRef
when (state.isTerminalState) {
true -> {
holder.progress.visibility = View.GONE
holder.toggle.visibility = View.GONE
when (state.state) {
Download.STATE_FAILED -> {
holder.toggle.setImageDrawable(context.getDrawable(R.drawable.retry))
holder.progress.visibility = View.GONE
}
else -> holder.toggle.visibility = View.GONE
}
}
false -> {
@ -53,25 +61,29 @@ class DownloadsAdapter(private val context: Context, private val listener: OnRef
}
Download.STATE_STOPPED -> holder.toggle.setImageIcon(Icon.createWithResource(context, R.drawable.play))
else -> holder.toggle.setImageIcon(Icon.createWithResource(context, R.drawable.pause))
}
}
}
holder.toggle.setOnClickListener {
if (state.state == Download.STATE_DOWNLOADING) {
DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, 1, false)
} else {
DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, Download.STOP_REASON_NONE, false)
}
when (state.state) {
Download.STATE_DOWNLOADING -> DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, 1, false)
listener.refresh()
Download.STATE_FAILED -> {
Track(download.id, download.title, Artist(0, download.artist, listOf()),Album(0, Album.Artist(""), "", Covers("")), 0, listOf(Track.Upload(download.contentId, 0, 0))).also {
PinService.download(context, it)
}
}
else -> DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, Download.STOP_REASON_NONE, false)
}
}
holder.delete.setOnClickListener {
listener.onItemRemoved(position)
DownloadService.sendRemoveDownload(context, PinService::class.java, download.contentId, false)
listener.refresh()
}
}
}

View File

@ -11,13 +11,16 @@ import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.TracksRepository
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_tracks.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
override val viewRes = R.layout.fragment_tracks
@ -78,11 +81,15 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
override fun onResume() {
super.onResume()
GlobalScope.launch(Main) {
GlobalScope.launch(IO) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
withContext(Main) {
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
}
refreshDownloadedTracks()
}
play.setOnClickListener {
@ -117,29 +124,46 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
GlobalScope.launch(IO) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> refreshCurrentTrack()
is Event.RefreshTrack -> refreshCurrentTrack()
is Event.DownloadChanged -> {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
}
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
private suspend fun refreshDownloadedTracks() {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
adapter.notifyDataSetChanged()
withContext(Main) {
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
adapter.notifyDataSetChanged()
}
}
private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
withContext(Main) {
adapter.data[match.second].downloaded = true
adapter.notifyItemChanged(match.second)
}
}
}
}
}
private fun refreshCurrentTrack() {
GlobalScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
private suspend fun refreshCurrentTrack() {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
withContext(Main) {
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}

View File

@ -1,25 +1,45 @@
package com.github.apognu.otter.playback
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.*
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.offline.DownloadRequest
import com.google.android.exoplayer2.offline.DownloadService
import com.google.android.exoplayer2.scheduler.Scheduler
import com.google.android.exoplayer2.ui.DownloadNotificationHelper
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.*
class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
private val manager by lazy {
val database = Otter.get().exoDatabase
val cache = Otter.get().exoCache
val helper = DownloaderConstructorHelper(cache, QueueManager.factory(this))
companion object {
fun download(context: Context, track: Track) {
track.bestUpload()?.let { upload ->
val url = mustNormalizeUrl(upload.listen_url)
val data = Gson().toJson(
DownloadInfo(
track.id,
url,
track.title,
track.artist.name,
null
)
).toByteArray()
DownloadManager(this, DefaultDownloadIndex(database), DefaultDownloaderFactory(helper))
DownloadRequest(url, DownloadRequest.TYPE_PROGRESSIVE, Uri.parse(url), Collections.emptyList(), null, data).also {
sendAddDownload(context, PinService::class.java, it, false)
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -36,22 +56,25 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
return super.onStartCommand(intent, flags, startId)
}
override fun getDownloadManager() = manager
override fun getDownloadManager() = Otter.get().exoDownloadManager.apply {
addListener(DownloadListener())
}
override fun getScheduler(): Scheduler? = null
override fun getForegroundNotification(downloads: MutableList<Download>?): Notification {
val quantity = downloads?.size ?: 0
val description = resources.getQuantityString(R.plurals.downloads_description, quantity, quantity)
override fun getForegroundNotification(downloads: MutableList<Download>): Notification {
val description = resources.getQuantityString(R.plurals.downloads_description, downloads.size, downloads.size)
return DownloadNotificationHelper(this, AppContext.NOTIFICATION_CHANNEL_DOWNLOADS).buildProgressNotification(R.drawable.downloads, null, description, downloads)
}
override fun onDownloadChanged(download: Download?) {
super.onDownloadChanged(download)
private fun getDownloads() = downloadManager.downloadIndex.getDownloads()
EventBus.send(Event.DownloadChanged)
inner class DownloadListener : DownloadManager.Listener {
override fun onDownloadChanged(downloadManager: DownloadManager, download: Download) {
super.onDownloadChanged(downloadManager, download)
EventBus.send(Event.DownloadChanged(download))
}
}
private fun getDownloads() = manager.downloadIndex.getDownloads()
}

View File

@ -8,7 +8,6 @@ import android.content.IntentFilter
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat
@ -16,13 +15,13 @@ import android.view.KeyEvent
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.offline.DownloadRequest
import com.google.android.exoplayer2.offline.DownloadService.sendAddDownload
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
@ -30,7 +29,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.*
class PlayerService : Service() {
private lateinit var queue: QueueManager
@ -88,7 +86,7 @@ class PlayerService : Service() {
mediaControlsManager = MediaControlsManager(this, mediaSession)
player = ExoPlayerFactory.newSimpleInstance(this).apply {
player = SimpleExoPlayer.Builder(this).build().apply {
playWhenReady = false
playerEventListener = PlayerEventListener().also {
@ -98,12 +96,12 @@ class PlayerService : Service() {
MediaSessionConnector(mediaSession).also {
it.setPlayer(this)
it.setMediaButtonEventHandler { player, _, mediaButtonEvent ->
mediaButtonEvent?.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
mediaButtonEvent.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
if (key.action == KeyEvent.ACTION_UP) {
when (key.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY -> state(true)
KeyEvent.KEYCODE_MEDIA_PAUSE -> state(false)
KeyEvent.KEYCODE_MEDIA_NEXT -> player?.next()
KeyEvent.KEYCODE_MEDIA_NEXT -> player.next()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> previousTrack()
}
}
@ -193,8 +191,8 @@ class PlayerService : Service() {
is Command.SetRepeatMode -> player.repeatMode = message.mode
is Command.PinTrack -> download(message.track)
is Command.PinTracks -> message.tracks.forEach { download(it) }
is Command.PinTrack -> PinService.download(this@PlayerService, message.track)
is Command.PinTracks -> message.tracks.forEach { PinService.download(this@PlayerService, it) }
}
if (player.playWhenReady) {
@ -255,7 +253,7 @@ class PlayerService : Service() {
state(false)
player.release()
Otter.get().exoCache?.release()
Otter.get().exoCache.release()
stopForeground(true)
stopSelf()
@ -340,25 +338,6 @@ class PlayerService : Service() {
player.seekTo(duration.toLong())
}
private fun download(track: Track) {
track.bestUpload()?.let { upload ->
val url = mustNormalizeUrl(upload.listen_url)
val data = Gson().toJson(
DownloadInfo(
track.id,
url,
track.title,
track.artist.name,
null
)
).toByteArray()
DownloadRequest(url, DownloadRequest.TYPE_PROGRESSIVE, Uri.parse(url), Collections.emptyList(), null, data).also {
sendAddDownload(this@PlayerService, PinService::class.java, it, false)
}
}
}
inner class PlayerEventListener : Player.EventListener {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
super.onPlayerStateChanged(playWhenReady, playbackState)

View File

@ -4,7 +4,6 @@ import android.content.Context
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.offline.Download
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
@ -26,7 +25,7 @@ class TracksRepository(override val context: Context?, albumId: Int) : Repositor
while (response.cursor.moveToNext()) {
val download = response.cursor.download
Gson().fromJson(String(download.request.data), DownloadInfo::class.java)?.let {
download.getMetadata()?.let {
if (download.state == Download.STATE_COMPLETED) {
ids.add(it.id)
}

View File

@ -1,6 +1,7 @@
package com.github.apognu.otter.utils
import com.github.apognu.otter.Otter
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadCursor
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
@ -47,7 +48,7 @@ sealed class Event {
object QueueChanged : Event()
object RadioStarted : Event()
object ListingsChanged : Event()
object DownloadChanged : Event()
class DownloadChanged(val download: Download) : Event()
}
sealed class Request(var channel: Channel<Response>? = null) {

View File

@ -6,6 +6,8 @@ import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.BrowseFragment
import com.github.apognu.otter.repositories.Repository
import com.github.kittinunf.fuel.core.Request
import com.google.android.exoplayer2.offline.Download
import com.google.gson.Gson
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import kotlinx.coroutines.Dispatchers.Main
@ -62,8 +64,8 @@ fun <T> T.applyOnApi(api: Int, block: T.() -> T): T {
}
fun Picasso.maybeLoad(url: String?): RequestCreator {
if (url == null) return load(R.drawable.cover)
else return load(url)
return if (url == null) load(R.drawable.cover)
else load(url)
}
fun Request.authorize(): Request {
@ -73,3 +75,5 @@ fun Request.authorize(): Request {
}
}
}
fun Download.getMetadata(): DownloadInfo? = Gson().fromJson(String(this.request.data), DownloadInfo::class.java)

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>