Ability to shuffle play all tracks from an artist. Should close #21. Also added animations over long-running operations.

This commit is contained in:
Antoine POPINEAU 2020-06-02 18:50:46 +02:00
parent cb43615cb1
commit c75f2e45f6
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
11 changed files with 277 additions and 46 deletions

View File

@ -7,12 +7,19 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.views.LoadingImageView
import kotlinx.android.synthetic.main.row_radio.view.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosAdapter(val context: Context?, private val listener: OnRadioClickListener) : FunkwhaleAdapter<Radio, RadiosAdapter.ViewHolder>() {
interface OnRadioClickListener {
fun onClick(holder: View?, radio: Radio)
fun onClick(holder: ViewHolder, radio: Radio)
}
override fun getItemCount() = data.size
@ -31,7 +38,6 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
val radio = data[position]
holder.art.visibility = View.VISIBLE
holder.nativeArt.visibility = View.GONE
holder.name.text = radio.name
holder.description.text = radio.description
@ -43,24 +49,46 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
}
icon?.let {
holder.art.visibility = View.GONE
holder.nativeArt.visibility = View.VISIBLE
holder.native = true
holder.nativeArt.setImageDrawable(context.getDrawable(icon))
holder.nativeArt.alpha = 0.7f
holder.nativeArt.setColorFilter(context.getColor(R.color.controlForeground))
holder.art.setImageDrawable(context.getDrawable(icon))
holder.art.alpha = 0.7f
holder.art.setColorFilter(context.getColor(R.color.controlForeground))
}
}
}
inner class ViewHolder(view: View, private val listener: OnRadioClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
val nativeArt = view.native_art
val art = view.art
val name = view.name
val description = view.description
var native = false
override fun onClick(view: View?) {
listener.onClick(view, data[layoutPosition])
listener.onClick(this, data[layoutPosition])
}
fun spin() {
context?.let {
val originalDrawable = art.drawable
val originalColorFilter = art.colorFilter
val imageAnimator = LoadingImageView.start(context, art)
art.setColorFilter(context.getColor(R.color.controlForeground))
GlobalScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.RadioStarted -> {
art.colorFilter = originalColorFilter
LoadingImageView.stop(context, originalDrawable, art, imageAnimator)
}
}
}
}
}
}
}
}

View File

@ -18,11 +18,18 @@ import com.github.apognu.otter.R
import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.adapters.AlbumsAdapter
import com.github.apognu.otter.repositories.AlbumsRepository
import com.github.apognu.otter.repositories.ArtistTracksRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.views.LoadingFlotingActionButton
import com.squareup.picasso.Picasso
import kotlinx.android.synthetic.main.fragment_albums.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -30,6 +37,8 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
override val viewRes = R.layout.fragment_albums
override val recycler: RecyclerView get() = albums
lateinit var artistTracksRepository: ArtistTracksRepository
var artistId = 0
var artistName = ""
var artistArt = ""
@ -91,6 +100,7 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
adapter = AlbumsAdapter(context, OnAlbumClickListener())
repository = AlbumsRepository(context, artistId)
artistTracksRepository = ArtistTracksRepository(context, artistId)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -107,7 +117,7 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
cover_background?.let { background ->
activity?.let { activity ->
GlobalScope.launch(Dispatchers.IO) {
GlobalScope.launch(IO) {
val width = DisplayMetrics().apply {
activity.windowManager.defaultDisplay.getMetrics(this)
}.widthPixels
@ -130,6 +140,25 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
}
artist.text = artistName
play.setOnClickListener {
val loaderAnimation = LoadingFlotingActionButton.start(play)
GlobalScope.launch(IO) {
artistTracksRepository.fetch(Repository.Origin.Network.origin)
.map { it.data }
.toList()
.flatten()
.shuffled()
.also {
CommandBus.send(Command.ReplaceQueue(it))
withContext(Main) {
LoadingFlotingActionButton.stop(play, loaderAnimation)
}
}
}
}
}
inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener {

View File

@ -1,15 +1,17 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import android.view.View
import androidx.core.view.forEach
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.RadiosAdapter
import com.github.apognu.otter.repositories.RadiosRepository
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.utils.*
import kotlinx.android.synthetic.main.fragment_radios.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosFragment : FunkwhaleFragment<Radio, RadiosAdapter>() {
override val viewRes = R.layout.fragment_radios
@ -23,8 +25,25 @@ class RadiosFragment : FunkwhaleFragment<Radio, RadiosAdapter>() {
}
inner class RadioClickListener : RadiosAdapter.OnRadioClickListener {
override fun onClick(holder: View?, radio: Radio) {
override fun onClick(holder: RadiosAdapter.ViewHolder, radio: Radio) {
holder.spin()
recycler.forEach {
it.isEnabled = false
it.isClickable = false
}
CommandBus.send(Command.PlayRadio(radio))
GlobalScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.RadioStarted -> recycler.forEach {
it.isEnabled = true
it.isClickable = true
}
}
}
}
}
}
}

View File

@ -126,6 +126,8 @@ class RadioPlayer(val context: Context) {
withContext(Main) {
context.toast(context.getString(R.string.radio_playback_error))
}
} finally {
EventBus.send(Event.RadioStarted)
}
}
}

View File

@ -0,0 +1,18 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.TracksCache
import com.github.apognu.otter.utils.TracksResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) : Repository<Track, TracksCache>() {
override val cacheId = "tracks-artist-${artistId}"
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=${artistId}", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
}

View File

@ -42,6 +42,7 @@ sealed class Event {
class RefreshTrack(val track: Track?, val play: Boolean) : Event()
class StateChanged(val playing: Boolean) : Event()
object QueueChanged : Event()
object RadioStarted : Event()
}
sealed class Request(var channel: Channel<Response>? = null) {

View File

@ -0,0 +1,59 @@
package com.github.apognu.otter.views
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.ImageView
import com.github.apognu.otter.R
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
object LoadingFlotingActionButton {
fun start(button: ExtendedFloatingActionButton): ObjectAnimator {
button.isEnabled = false
button.setIconResource(R.drawable.fab_spinner)
button.shrink()
return ObjectAnimator.ofFloat(button, View.ROTATION, 0f, 360f).apply {
duration = 500
repeatCount = ObjectAnimator.INFINITE
start()
}
}
fun stop(button: ExtendedFloatingActionButton, animator: ObjectAnimator) {
animator.cancel()
button.isEnabled = true
button.setIconResource(R.drawable.play)
button.rotation = 0.0f
button.extend()
}
}
object LoadingImageView {
fun start(context: Context?, image: ImageView): ObjectAnimator? {
context?.let {
image.isEnabled = false
image.setImageDrawable(context.getDrawable(R.drawable.fab_spinner))
return ObjectAnimator.ofFloat(image, View.ROTATION, 0f, 360f).apply {
duration = 500
repeatCount = ObjectAnimator.INFINITE
start()
}
}
return null
}
fun stop(context: Context?, original: Drawable, image: ImageView, animator: ObjectAnimator?) {
context?.let {
animator?.cancel()
image.isEnabled = true
image.setImageDrawable(original)
image.rotation = 0.0f
}
}
}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<rotate
android:fromDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:toDegrees="360">
<shape
android:shape="ring"
android:thickness="3dp"
android:type="sweep"
android:useLevel="false">
<gradient
android:angle="0"
android:centerColor="#00ffffff"
android:endColor="#00ffffff"
android:startColor="#ffffffff"
android:type="sweep"
android:useLevel="false" />
</shape>
</rotate>
</item>
</layer-list>

View File

@ -39,6 +39,40 @@
android:gravity="center_vertical"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/cover"
android:layout_width="match_parent"
android:layout_height="100dp"
android:contentDescription="@string/alt_artist_art"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
tools:src="@tools:sample/avatars" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/colorPrimary"
android:elevation="10dp"
android:text="@string/playback_shuffle"
android:textColor="@android:color/white"
app:icon="@drawable/play"
app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="@id/cover"
app:layout_constraintLeft_toLeftOf="@id/cover"
app:layout_constraintRight_toRightOf="@id/cover"
app:layout_constraintTop_toBottomOf="@id/cover" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -53,34 +53,56 @@
app:layout_constraintVertical_bias="0"
tools:src="@tools:sample/avatars" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/colorPrimary"
android:elevation="10dp"
android:text="@string/playback_shuffle"
android:textColor="@android:color/white"
app:icon="@drawable/play"
app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="@id/cover"
app:layout_constraintLeft_toLeftOf="@id/cover"
app:layout_constraintRight_toRightOf="@id/cover"
app:layout_constraintTop_toBottomOf="@id/cover" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="vertical">
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/albums"
android:textAllCaps="true"
android:textSize="14sp" />
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/artist"
style="@style/AppTheme.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
tools:text="Muse" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/albums"
android:textAllCaps="true"
android:textSize="14sp" />
<TextView
android:id="@+id/artist"
style="@style/AppTheme.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
tools:text="Muse" />
</LinearLayout>
</LinearLayout>

View File

@ -13,23 +13,12 @@
android:transitionGroup="true"
tools:showIn="@layout/fragment_radios">
<ImageView
android:id="@+id/native_art"
android:layout_width="48dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/alt_album_cover"
android:scaleType="fitCenter"
android:src="@drawable/cover"
android:visibility="gone"
tools:src="@tools:sample/avatars" />
<com.github.apognu.otter.views.SquareImageView
android:id="@+id/art"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:scaleType="centerCrop"
android:scaleType="centerInside"
android:src="@drawable/cover"
tools:src="@tools:sample/avatars" />