ui: add animated playing indicator [#218]

Make the playing indicator animate when playback is ongoing.

Previously state issues stopped me from doing this, but apparently this
time I miraculously got it working. Yay.

Resolves #218.
This commit is contained in:
Alexander Capehart 2022-09-02 13:13:41 -06:00
parent 227a258eca
commit acaf679000
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
25 changed files with 879 additions and 287 deletions

View file

@ -3,6 +3,9 @@
## dev ## dev
#### What's New #### What's New
- Improved playing indicators [#218]
- Search and library now show playing indicators
- Playing indicators are now animated when playback is ongoing
- Added smooth seeking - Added smooth seeking
- Queue now has a fast scroller - Queue now has a fast scroller

View file

@ -69,6 +69,8 @@ dependencies {
// --- SUPPORT --- // --- SUPPORT ---
// General // General
// 1.4.0 is used in order to avoid a ripple bug in material components
implementation "androidx.appcompat:appcompat:1.4.0"
implementation "androidx.core:core-ktx:1.8.0" implementation "androidx.core:core-ktx:1.8.0"
implementation "androidx.activity:activity-ktx:1.6.0-rc01" implementation "androidx.activity:activity-ktx:1.6.0-rc01"
implementation "androidx.fragment:fragment-ktx:1.5.2" implementation "androidx.fragment:fragment-ktx:1.5.2"

View file

@ -96,7 +96,8 @@ class AlbumDetailFragment :
collectImmediately(detailModel.currentAlbum, ::handleItemChange) collectImmediately(detailModel.currentAlbum, ::handleItemChange)
collectImmediately(detailModel.albumData, detailAdapter::submitList) collectImmediately(detailModel.albumData, detailAdapter::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem, ::handleNavigation)
} }
@ -135,6 +136,7 @@ class AlbumDetailFragment :
override fun onOpenMenu(item: Item, anchor: View) { override fun onOpenMenu(item: Item, anchor: View) {
if (item is Song) { if (item is Song) {
musicMenu(anchor, R.menu.menu_album_song_actions, item) musicMenu(anchor, R.menu.menu_album_song_actions, item)
return
} }
error("Unexpected datatype when opening menu: ${item::class.java}") error("Unexpected datatype when opening menu: ${item::class.java}")
@ -244,7 +246,7 @@ class AlbumDetailFragment :
} }
} }
private fun updatePlayback(song: Song?, parent: MusicParent?) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
val binding = requireBinding() val binding = requireBinding()
for (item in binding.detailToolbar.menu.children) { for (item in binding.detailToolbar.menu.children) {
@ -257,10 +259,10 @@ class AlbumDetailFragment :
} }
if (parent is Album && parent.id == unlikelyToBeNull(detailModel.currentAlbum.value).id) { if (parent is Album && parent.id == unlikelyToBeNull(detailModel.currentAlbum.value).id) {
detailAdapter.activateSong(song) detailAdapter.updateIndicator(song, isPlaying)
} else { } else {
// Clear the ViewHolders if the mode isn't ALL_SONGS // Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.activateSong(null) detailAdapter.updateIndicator(null, isPlaying)
} }
} }

View file

@ -91,7 +91,8 @@ class ArtistDetailFragment :
collectImmediately(detailModel.currentArtist, ::handleItemChange) collectImmediately(detailModel.currentArtist, ::handleItemChange)
collectImmediately(detailModel.artistData, detailAdapter::submitList) collectImmediately(detailModel.artistData, detailAdapter::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem, ::handleNavigation)
} }
@ -200,20 +201,17 @@ class ArtistDetailFragment :
} }
} }
private fun updatePlayback(song: Song?, parent: MusicParent?) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) { var item: Item? = null
detailAdapter.activateSong(song)
} else { if (parent is Album) {
// Ignore song playback not from the artist item = parent
detailAdapter.activateSong(null)
} }
if (parent is Album && if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
parent.artist.id == unlikelyToBeNull(detailModel.currentArtist.value).id) { item = song
detailAdapter.activateAlbum(parent) }
} else {
// Ignore album playback not from the artist detailAdapter.updateIndicator(item, isPlaying)
detailAdapter.activateAlbum(null)
}
} }
} }

View file

@ -92,7 +92,8 @@ class GenreDetailFragment :
collectImmediately(detailModel.currentGenre, ::handleItemChange) collectImmediately(detailModel.currentGenre, ::handleItemChange)
collectImmediately(detailModel.genreData, detailAdapter::submitList) collectImmediately(detailModel.genreData, detailAdapter::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem, ::handleNavigation)
} }
@ -129,10 +130,11 @@ class GenreDetailFragment :
} }
override fun onOpenMenu(item: Item, anchor: View) { override fun onOpenMenu(item: Item, anchor: View) {
when (item) { if (item is Song) {
is Song -> musicMenu(anchor, R.menu.menu_song_actions, item) musicMenu(anchor, R.menu.menu_song_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
} }
error("Unexpected datatype when opening menu: ${item::class.java}")
} }
override fun onPlayParent() { override fun onPlayParent() {
@ -193,12 +195,12 @@ class GenreDetailFragment :
} }
} }
private fun updatePlayback(song: Song?, parent: MusicParent?) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent is Genre && parent.id == unlikelyToBeNull(detailModel.currentGenre.value).id) { if (parent is Genre && parent.id == unlikelyToBeNull(detailModel.currentGenre.value).id) {
detailAdapter.activateSong(song) detailAdapter.updateIndicator(song, isPlaying)
} else { } else {
// Ignore song playback not from the genre // Ignore song playback not from the genre
detailAdapter.activateSong(null) detailAdapter.updateIndicator(null, isPlaying)
} }
} }
} }

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.detail.DiscHeader import org.oxycblt.auxio.detail.DiscHeader
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.recycler.IndicatorViewHolder
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
@ -43,7 +44,6 @@ import org.oxycblt.auxio.util.inflater
*/ */
class AlbumDetailAdapter(private val listener: Listener) : class AlbumDetailAdapter(private val listener: Listener) :
DetailAdapter<AlbumDetailAdapter.Listener>(listener, DIFFER) { DetailAdapter<AlbumDetailAdapter.Listener>(listener, DIFFER) {
private var currentSong: Song? = null
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (differ.currentList[position]) { when (differ.currentList[position]) {
@ -77,18 +77,6 @@ class AlbumDetailAdapter(private val listener: Listener) :
} }
} }
override fun shouldActivateViewHolder(position: Int): Boolean {
val item = differ.currentList[position]
return item is Song && item.id == currentSong?.id
}
/** Update the [song] that this adapter should indicate playback */
fun activateSong(song: Song?) {
if (song == currentSong) return
activateImpl(differ.currentList, currentSong, song)
currentSong = song
}
companion object { companion object {
private val DIFFER = private val DIFFER =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
@ -182,7 +170,7 @@ class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
} }
private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) : private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) :
RecyclerView.ViewHolder(binding.root) { IndicatorViewHolder(binding.root) {
fun bind(item: Song, listener: MenuItemListener) { fun bind(item: Song, listener: MenuItemListener) {
// Hide the track number view if the song does not have a track. // Hide the track number view if the song does not have a track.
if (item.track != null) { if (item.track != null) {
@ -210,6 +198,11 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
binding.root.setOnClickListener { listener.onItemClick(item) } binding.root.setOnClickListener { listener.onItemClick(item) }
} }
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.songTrackBg.isPlaying = isPlaying
}
companion object { companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_SONG const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_SONG

View file

@ -30,6 +30,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveYear import org.oxycblt.auxio.music.resolveYear
import org.oxycblt.auxio.ui.recycler.ArtistViewHolder import org.oxycblt.auxio.ui.recycler.ArtistViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorViewHolder
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
@ -44,8 +45,6 @@ import org.oxycblt.auxio.util.inflater
*/ */
class ArtistDetailAdapter(private val listener: Listener) : class ArtistDetailAdapter(private val listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) { DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentAlbum: Album? = null
private var currentSong: Song? = null
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (differ.currentList[position]) { when (differ.currentList[position]) {
@ -79,26 +78,6 @@ class ArtistDetailAdapter(private val listener: Listener) :
} }
} }
override fun shouldActivateViewHolder(position: Int): Boolean {
val item = differ.currentList[position]
return (item is Album && item.id == currentAlbum?.id) ||
(item is Song && item.id == currentSong?.id)
}
/** Update the [album] that this adapter should indicate playback */
fun activateAlbum(album: Album?) {
if (album == currentAlbum) return
activateImpl(differ.currentList, currentAlbum, album)
currentAlbum = album
}
/** Update the [song] that this adapter should indicate playback */
fun activateSong(song: Song?) {
if (song == currentSong) return
activateImpl(differ.currentList, currentSong, song)
currentSong = song
}
companion object { companion object {
private val DIFFER = private val DIFFER =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
@ -158,7 +137,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
private class ArtistAlbumViewHolder private class ArtistAlbumViewHolder
private constructor( private constructor(
private val binding: ItemParentBinding, private val binding: ItemParentBinding,
) : RecyclerView.ViewHolder(binding.root) { ) : IndicatorViewHolder(binding.root) {
fun bind(item: Album, listener: MenuItemListener) { fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bind(item) binding.parentImage.bind(item)
binding.parentName.text = item.resolveName(binding.context) binding.parentName.text = item.resolveName(binding.context)
@ -171,6 +150,11 @@ private constructor(
binding.root.setOnClickListener { listener.onItemClick(item) } binding.root.setOnClickListener { listener.onItemClick(item) }
} }
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.parentImage.isPlaying = isPlaying
}
companion object { companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_ALBUM const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_ALBUM
@ -188,7 +172,7 @@ private constructor(
private class ArtistSongViewHolder private class ArtistSongViewHolder
private constructor( private constructor(
private val binding: ItemSongBinding, private val binding: ItemSongBinding,
) : RecyclerView.ViewHolder(binding.root) { ) : IndicatorViewHolder(binding.root) {
fun bind(item: Song, listener: MenuItemListener) { fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bind(item) binding.songAlbumCover.bind(item)
binding.songName.text = item.resolveName(binding.context) binding.songName.text = item.resolveName(binding.context)
@ -201,6 +185,11 @@ private constructor(
binding.root.setOnClickListener { listener.onItemClick(item) } binding.root.setOnClickListener { listener.onItemClick(item) }
} }
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.songAlbumCover.isPlaying = isPlaying
}
companion object { companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_SONG const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_SONG

View file

@ -26,9 +26,9 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.detail.SortHeader import org.oxycblt.auxio.detail.SortHeader
import org.oxycblt.auxio.ui.recycler.ActivationAdapter
import org.oxycblt.auxio.ui.recycler.Header import org.oxycblt.auxio.ui.recycler.Header
import org.oxycblt.auxio.ui.recycler.HeaderViewHolder import org.oxycblt.auxio.ui.recycler.HeaderViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
@ -38,7 +38,9 @@ import org.oxycblt.auxio.util.inflater
abstract class DetailAdapter<L : DetailAdapter.Listener>( abstract class DetailAdapter<L : DetailAdapter.Listener>(
private val listener: L, private val listener: L,
diffCallback: DiffUtil.ItemCallback<Item> diffCallback: DiffUtil.ItemCallback<Item>
) : ActivationAdapter<RecyclerView.ViewHolder>() { ) : IndicatorAdapter<RecyclerView.ViewHolder>() {
private var isPlaying = false
@Suppress("LeakingThis") override fun getItemCount() = differ.currentList.size @Suppress("LeakingThis") override fun getItemCount() = differ.currentList.size
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
@ -77,7 +79,7 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
protected val differ = AsyncListDiffer(this, diffCallback) protected val differ = AsyncListDiffer(this, diffCallback)
val currentList: List<Item> override val currentList: List<Item>
get() = differ.currentList get() = differ.currentList
fun submitList(list: List<Item>) { fun submitList(list: List<Item>) {

View file

@ -40,6 +40,7 @@ import org.oxycblt.auxio.util.inflater
class GenreDetailAdapter(private val listener: Listener) : class GenreDetailAdapter(private val listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) { DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentSong: Song? = null private var currentSong: Song? = null
private var isPlaying = false
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (differ.currentList[position]) { when (differ.currentList[position]) {
@ -70,18 +71,6 @@ class GenreDetailAdapter(private val listener: Listener) :
} }
} }
override fun shouldActivateViewHolder(position: Int): Boolean {
val item = differ.currentList[position]
return item is Song && item.id == currentSong?.id
}
/** Update the [song] that this adapter should indicate playback */
fun activateSong(song: Song?) {
if (song == currentSong) return
activateImpl(differ.currentList, currentSong, song)
currentSong = song
}
companion object { companion object {
val DIFFER = val DIFFER =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {

View file

@ -29,8 +29,8 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.ActivationAdapter
import org.oxycblt.auxio.ui.recycler.AlbumViewHolder import org.oxycblt.auxio.ui.recycler.AlbumViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SyncListDiffer import org.oxycblt.auxio.ui.recycler.SyncListDiffer
@ -56,7 +56,7 @@ class AlbumListFragment : HomeListFragment<Album>() {
} }
collectImmediately(homeModel.albums, homeAdapter::replaceList) collectImmediately(homeModel.albums, homeAdapter::replaceList)
collectImmediately(playbackModel.parent, ::handleParent) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handleParent)
} }
override fun getPopup(pos: Int): String? { override fun getPopup(pos: Int): String? {
@ -109,19 +109,21 @@ class AlbumListFragment : HomeListFragment<Album>() {
} }
} }
private fun handleParent(parent: MusicParent?) { private fun handleParent(parent: MusicParent?, isPlaying: Boolean) {
if (parent is Album) { if (parent is Album) {
homeAdapter.activateAlbum(parent) homeAdapter.updateIndicator(parent, isPlaying)
} else { } else {
// Ignore playback not from albums // Ignore playback not from albums
homeAdapter.activateAlbum(null) homeAdapter.updateIndicator(null, isPlaying)
} }
} }
private class AlbumAdapter(private val listener: MenuItemListener) : private class AlbumAdapter(private val listener: MenuItemListener) :
ActivationAdapter<AlbumViewHolder>() { IndicatorAdapter<AlbumViewHolder>() {
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFFER) private val differ = SyncListDiffer(this, AlbumViewHolder.DIFFER)
private var currentAlbum: Album? = null
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size override fun getItemCount() = differ.currentList.size
@ -136,20 +138,8 @@ class AlbumListFragment : HomeListFragment<Album>() {
} }
} }
override fun shouldActivateViewHolder(position: Int): Boolean {
val item = differ.currentList[position]
return item.id == currentAlbum?.id
}
fun replaceList(newList: List<Album>) { fun replaceList(newList: List<Album>) {
differ.replaceList(newList) differ.replaceList(newList)
} }
/** Update the [album] that this adapter should indicate playback */
fun activateAlbum(album: Album?) {
if (album == currentAlbum) return
activateImpl(differ.currentList, currentAlbum, album)
currentAlbum = album
}
} }
} }

View file

@ -27,8 +27,8 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.ActivationAdapter
import org.oxycblt.auxio.ui.recycler.ArtistViewHolder import org.oxycblt.auxio.ui.recycler.ArtistViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SyncListDiffer import org.oxycblt.auxio.ui.recycler.SyncListDiffer
@ -51,7 +51,7 @@ class ArtistListFragment : HomeListFragment<Artist>() {
} }
collectImmediately(homeModel.artists, homeAdapter::replaceList) collectImmediately(homeModel.artists, homeAdapter::replaceList)
collectImmediately(playbackModel.parent, ::handleParent) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handleParent)
} }
override fun getPopup(pos: Int): String? { override fun getPopup(pos: Int): String? {
@ -85,19 +85,21 @@ class ArtistListFragment : HomeListFragment<Artist>() {
} }
} }
private fun handleParent(parent: MusicParent?) { private fun handleParent(parent: MusicParent?, isPlaying: Boolean) {
if (parent is Artist) { if (parent is Artist) {
homeAdapter.activateArtist(parent) homeAdapter.updateIndicator(parent, isPlaying)
} else { } else {
// Ignore playback not from artists // Ignore playback not from artists
homeAdapter.activateArtist(null) homeAdapter.updateIndicator(null, isPlaying)
} }
} }
private class ArtistAdapter(private val listener: MenuItemListener) : private class ArtistAdapter(private val listener: MenuItemListener) :
ActivationAdapter<ArtistViewHolder>() { IndicatorAdapter<ArtistViewHolder>() {
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFFER) private val differ = SyncListDiffer(this, ArtistViewHolder.DIFFER)
private var currentArtist: Artist? = null
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size override fun getItemCount() = differ.currentList.size
@ -116,20 +118,8 @@ class ArtistListFragment : HomeListFragment<Artist>() {
} }
} }
override fun shouldActivateViewHolder(position: Int): Boolean {
val item = differ.currentList[position]
return item.id == currentArtist?.id
}
fun replaceList(newList: List<Artist>) { fun replaceList(newList: List<Artist>) {
differ.replaceList(newList) differ.replaceList(newList)
} }
/** Update the [artist] that this adapter should indicate playback */
fun activateArtist(artist: Artist?) {
if (artist == currentArtist) return
activateImpl(differ.currentList, currentArtist, artist)
currentArtist = artist
}
} }
} }

View file

@ -27,8 +27,8 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.ActivationAdapter
import org.oxycblt.auxio.ui.recycler.GenreViewHolder import org.oxycblt.auxio.ui.recycler.GenreViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SyncListDiffer import org.oxycblt.auxio.ui.recycler.SyncListDiffer
@ -51,7 +51,7 @@ class GenreListFragment : HomeListFragment<Genre>() {
} }
collectImmediately(homeModel.genres, homeAdapter::replaceList) collectImmediately(homeModel.genres, homeAdapter::replaceList)
collectImmediately(playbackModel.parent, ::handlePlayback) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handlePlayback)
} }
override fun getPopup(pos: Int): String? { override fun getPopup(pos: Int): String? {
@ -85,19 +85,21 @@ class GenreListFragment : HomeListFragment<Genre>() {
} }
} }
private fun handlePlayback(parent: MusicParent?) { private fun handlePlayback(parent: MusicParent?, isPlaying: Boolean) {
if (parent is Genre) { if (parent is Genre) {
homeAdapter.activateGenre(parent) homeAdapter.updateIndicator(parent, isPlaying)
} else { } else {
// Ignore playback not from genres // Ignore playback not from genres
homeAdapter.activateGenre(null) homeAdapter.updateIndicator(null, isPlaying)
} }
} }
private class GenreAdapter(private val listener: MenuItemListener) : private class GenreAdapter(private val listener: MenuItemListener) :
ActivationAdapter<GenreViewHolder>() { IndicatorAdapter<GenreViewHolder>() {
private val differ = SyncListDiffer(this, GenreViewHolder.DIFFER) private val differ = SyncListDiffer(this, GenreViewHolder.DIFFER)
private var currentGenre: Genre? = null
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size override fun getItemCount() = differ.currentList.size
@ -112,20 +114,8 @@ class GenreListFragment : HomeListFragment<Genre>() {
} }
} }
override fun shouldActivateViewHolder(position: Int): Boolean {
val item = differ.currentList[position]
return item.id == currentGenre?.id
}
fun replaceList(newList: List<Genre>) { fun replaceList(newList: List<Genre>) {
differ.replaceList(newList) differ.replaceList(newList)
} }
/** Update the [genre] that this adapter should indicate playback */
fun activateGenre(genre: Genre?) {
if (genre == currentGenre) return
activateImpl(differ.currentList, currentGenre, genre)
currentGenre = genre
}
} }
} }

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.ActivationAdapter import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SongViewHolder import org.oxycblt.auxio.ui.recycler.SongViewHolder
@ -58,7 +58,8 @@ class SongListFragment : HomeListFragment<Song>() {
} }
collectImmediately(homeModel.songs, homeAdapter::replaceList) collectImmediately(homeModel.songs, homeAdapter::replaceList)
collectImmediately(playbackModel.song, playbackModel.parent, ::handlePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::handlePlayback)
} }
override fun getPopup(pos: Int): String? { override fun getPopup(pos: Int): String? {
@ -113,19 +114,21 @@ class SongListFragment : HomeListFragment<Song>() {
} }
} }
private fun handlePlayback(song: Song?, parent: MusicParent?) { private fun handlePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent == null) { if (parent == null) {
homeAdapter.activateSong(song) homeAdapter.updateIndicator(song, isPlaying)
} else { } else {
// Ignore playback that is not from all songs // Ignore playback that is not from all songs
homeAdapter.activateSong(null) homeAdapter.updateIndicator(null, isPlaying)
} }
} }
private class SongAdapter(private val listener: MenuItemListener) : private class SongAdapter(private val listener: MenuItemListener) :
ActivationAdapter<SongViewHolder>() { IndicatorAdapter<SongViewHolder>() {
private val differ = SyncListDiffer(this, SongViewHolder.DIFFER) private val differ = SyncListDiffer(this, SongViewHolder.DIFFER)
private var currentSong: Song? = null
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size override fun getItemCount() = differ.currentList.size
@ -140,20 +143,8 @@ class SongListFragment : HomeListFragment<Song>() {
} }
} }
override fun shouldActivateViewHolder(position: Int): Boolean {
val item = differ.currentList[position]
return item.id == currentSong?.id
}
fun replaceList(newList: List<Song>) { fun replaceList(newList: List<Song>) {
differ.replaceList(newList) differ.replaceList(newList)
} }
/** Update the [song] that this adapter should indicate playback */
fun activateSong(song: Song?) {
if (song == currentSong) return
activateImpl(differ.currentList, currentSong, song)
currentSong = song
}
} }
} }

View file

@ -30,7 +30,6 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
/** /**
* Effectively a super-charged [StyledImageView]. * Effectively a super-charged [StyledImageView].
@ -52,7 +51,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val cornerRadius: Float private val cornerRadius: Float
private val inner: StyledImageView private val inner: StyledImageView
private var customView: View? = null private var customView: View? = null
private val indicator: StyledImageView private val indicator: IndicatorView
init { init {
// Android wants you to make separate attributes for each view type, but will // Android wants you to make separate attributes for each view type, but will
@ -63,11 +62,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
styledAttrs.recycle() styledAttrs.recycle()
inner = StyledImageView(context, attrs) inner = StyledImageView(context, attrs)
indicator = indicator = IndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius }
StyledImageView(context).apply {
cornerRadius = this@ImageGroup.cornerRadius
staticIcon = context.getDrawableCompat(R.drawable.ic_currently_playing_24)
}
addView(inner) addView(inner)
} }
@ -101,6 +96,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
invalidateIndicator() invalidateIndicator()
} }
fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {}
var isPlaying: Boolean
get() = indicator.isPlaying
set(value) {
indicator.isPlaying = value
}
override fun setEnabled(enabled: Boolean) { override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled) super.setEnabled(enabled)
invalidateIndicator() invalidateIndicator()
@ -109,14 +112,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private fun invalidateIndicator() { private fun invalidateIndicator() {
if (isActivated) { if (isActivated) {
alpha = 1f alpha = 1f
indicator.alpha = 1f
customView?.alpha = 0f customView?.alpha = 0f
inner.alpha = 0f inner.alpha = 0f
indicator.alpha = 1f
} else { } else {
alpha = if (isEnabled) 1f else 0.5f alpha = if (isEnabled) 1f else 0.5f
indicator.alpha = 0f
customView?.alpha = 1f customView?.alpha = 1f
inner.alpha = 1f inner.alpha = 1f
indicator.alpha = 0f
} }
} }

View file

@ -0,0 +1,127 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image
import android.content.Context
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.drawable.AnimationDrawable
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.widget.ImageViewCompat
import com.google.android.material.shape.MaterialShapeDrawable
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
/**
* View that displays the playback indicator. Nominally emulates [StyledImageView], but is
* much different internally as an animated icon can't be wrapped within StyledDrawable without
* causing insane issues.
* @author OxygenCobalt
*/
class IndicatorView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) {
private val playingIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable
private val pausedIndicatorDrawable = context.getDrawableCompat(R.drawable.ic_paused_indicator_24)
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
private val settings = Settings(context)
var cornerRadius = 0f
set(value) {
field = value
(background as? MaterialShapeDrawable)?.let { bg ->
if (settings.roundMode) {
bg.setCornerSize(value)
} else {
bg.setCornerSize(0f)
}
}
}
init {
// Use clipToOutline and a background drawable to crop images. While Coil's transformation
// could theoretically be used to round corners, the corner radius is dependent on the
// dimensions of the image, which will result in inconsistent corners across different
// album covers unless we resize all covers to be the same size. clipToOutline is both
// cheaper and more elegant. As a side-note, this also allows us to re-use the same
// background for both the tonal background color and the corner rounding.
clipToOutline = true
background =
MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg)
setCornerSize(cornerRadius)
}
scaleType = ScaleType.MATRIX
ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg))
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val iconSize = max(measuredWidth, measuredHeight) / 2
imageMatrix =
indicatorMatrix.apply {
reset()
drawable?.let { drawable ->
// Android is too good to allow us to set a fixed image size, so we instead need
// to define a matrix to scale an image directly.
// First scale the icon up to the desired size.
indicatorMatrixSrc.set(
0f,
0f,
drawable.intrinsicWidth.toFloat(),
drawable.intrinsicHeight.toFloat())
indicatorMatrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat())
indicatorMatrix.setRectToRect(
indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER)
// Then actually center it into the icon, which the previous call does not
// actually do.
indicatorMatrix.postTranslate(
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f)
}
}
}
var isPlaying: Boolean
get() = drawable == playingIndicatorDrawable
set(value) {
if (value) {
playingIndicatorDrawable.start()
setImageDrawable(playingIndicatorDrawable)
} else {
playingIndicatorDrawable.stop()
setImageDrawable(pausedIndicatorDrawable)
}
}
}

View file

@ -35,6 +35,7 @@ class QueueAdapter(private val listener: QueueItemListener) :
RecyclerView.Adapter<QueueSongViewHolder>() { RecyclerView.Adapter<QueueSongViewHolder>() {
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFFER) private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFFER)
private var currentIndex = 0 private var currentIndex = 0
private var isPlaying = false
override fun getItemCount() = differ.currentList.size override fun getItemCount() = differ.currentList.size
@ -54,7 +55,7 @@ class QueueAdapter(private val listener: QueueItemListener) :
} }
viewHolder.isEnabled = position > currentIndex viewHolder.isEnabled = position > currentIndex
viewHolder.isActivated = position == currentIndex viewHolder.updateIndicator(position == currentIndex, isPlaying)
} }
fun submitList(newList: List<Song>) { fun submitList(newList: List<Song>) {
@ -65,18 +66,32 @@ class QueueAdapter(private val listener: QueueItemListener) :
differ.replaceList(newList) differ.replaceList(newList)
} }
fun updateIndex(index: Int) { fun updateIndicator(index: Int, isPlaying: Boolean) {
var updatedIndex = false
if (index != currentIndex) {
when { when {
index < currentIndex -> { index < currentIndex -> {
val lastIndex = currentIndex val lastIndex = currentIndex
currentIndex = index currentIndex = index
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_INDEX) notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_INDEX)
} }
index > currentIndex -> { else -> {
currentIndex = index currentIndex = index
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_INDEX) notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_INDEX)
} }
} }
updatedIndex = true
}
if (this.isPlaying != isPlaying) {
this.isPlaying = isPlaying
if (!updatedIndex) {
notifyItemChanged(index, PAYLOAD_UPDATE_INDEX)
}
}
} }
companion object { companion object {
@ -92,7 +107,7 @@ interface QueueItemListener {
class QueueSongViewHolder class QueueSongViewHolder
private constructor( private constructor(
private val binding: ItemQueueSongBinding, private val binding: ItemQueueSongBinding,
) : RecyclerView.ViewHolder(binding.root) { ) : IndicatorViewHolder(binding.root) {
val bodyView: View val bodyView: View
get() = binding.body get() = binding.body
val backgroundView: View val backgroundView: View
@ -146,11 +161,9 @@ private constructor(
binding.songDragHandle.isEnabled = value binding.songDragHandle.isEnabled = value
} }
var isActivated: Boolean override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
get() = binding.interactBody.isActivated binding.interactBody.isActivated = isActive
set(value) { binding.songAlbumCover.isPlaying = isPlaying
// Activation does not affect clicking, make everything activated.
binding.interactBody.isActivated = value
} }
companion object { companion object {

View file

@ -27,7 +27,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -38,6 +40,7 @@ import org.oxycblt.auxio.util.logD
*/ */
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemListener { class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemListener {
private val queueModel: QueueViewModel by activityViewModels() private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val queueAdapter = QueueAdapter(this) private val queueAdapter = QueueAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject { private val touchHelper: ItemTouchHelper by lifecycleObject {
ItemTouchHelper(QueueDragCallback(queueModel)) ItemTouchHelper(QueueDragCallback(queueModel))
@ -63,7 +66,8 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
// --- VIEWMODEL SETUP ---- // --- VIEWMODEL SETUP ----
collectImmediately(queueModel.queue, queueModel.index, ::updateQueue) collectImmediately(
queueModel.queue, queueModel.index, playbackModel.isPlaying, ::updateQueue)
} }
override fun onDestroyBinding(binding: FragmentQueueBinding) { override fun onDestroyBinding(binding: FragmentQueueBinding) {
@ -79,7 +83,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
touchHelper.startDrag(viewHolder) touchHelper.startDrag(viewHolder)
} }
private fun updateQueue(queue: List<Song>, index: Int) { private fun updateQueue(queue: List<Song>, index: Int, isPlaying: Boolean) {
val binding = requireBinding() val binding = requireBinding()
val replaceQueue = queueModel.replaceQueue val replaceQueue = queueModel.replaceQueue
@ -111,7 +115,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
queueModel.finishScrollTo() queueModel.finishScrollTo()
queueAdapter.updateIndex(index) queueAdapter.updateIndicator(index, isPlaying)
} }
private fun invalidateDivider() { private fun invalidateDivider() {

View file

@ -24,24 +24,20 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.recycler.ActivationAdapter
import org.oxycblt.auxio.ui.recycler.AlbumViewHolder import org.oxycblt.auxio.ui.recycler.AlbumViewHolder
import org.oxycblt.auxio.ui.recycler.ArtistViewHolder import org.oxycblt.auxio.ui.recycler.ArtistViewHolder
import org.oxycblt.auxio.ui.recycler.GenreViewHolder import org.oxycblt.auxio.ui.recycler.GenreViewHolder
import org.oxycblt.auxio.ui.recycler.Header import org.oxycblt.auxio.ui.recycler.Header
import org.oxycblt.auxio.ui.recycler.HeaderViewHolder import org.oxycblt.auxio.ui.recycler.HeaderViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
import org.oxycblt.auxio.ui.recycler.SongViewHolder import org.oxycblt.auxio.ui.recycler.SongViewHolder
class SearchAdapter(private val listener: MenuItemListener) : class SearchAdapter(private val listener: MenuItemListener) :
ActivationAdapter<RecyclerView.ViewHolder>() { IndicatorAdapter<RecyclerView.ViewHolder>() {
private val differ = AsyncListDiffer(this, DIFFER) private val differ = AsyncListDiffer(this, DIFFER)
private var currentSong: Song? = null
private var currentAlbum: Album? = null
private var currentArtist: Artist? = null
private var currentGenre: Genre? = null
override fun getItemCount() = differ.currentList.size override fun getItemCount() = differ.currentList.size
@ -83,44 +79,11 @@ class SearchAdapter(private val listener: MenuItemListener) :
} }
} }
override fun shouldActivateViewHolder(position: Int): Boolean { override val currentList: List<Item>
val item = differ.currentList[position]
return (item is Song && item.id == currentSong?.id) ||
(item is Album && item.id == currentAlbum?.id) ||
(item is Artist && item.id == currentArtist?.id) ||
(item is Genre && item.id == currentGenre?.id)
}
val currentList: List<Item>
get() = differ.currentList get() = differ.currentList
fun submitList(list: List<Item>, callback: () -> Unit) = differ.submitList(list, callback) fun submitList(list: List<Item>, callback: () -> Unit) = differ.submitList(list, callback)
fun activateSong(song: Song?) {
if (song == currentSong) return
activateImpl(differ.currentList, currentSong, song)
currentSong = song
}
fun activateAlbum(album: Album?) {
if (album == currentAlbum) return
activateImpl(differ.currentList, currentAlbum, album)
currentAlbum = album
}
fun activateArtist(artist: Artist?) {
if (artist == currentArtist) return
activateImpl(differ.currentList, currentArtist, artist)
currentArtist = artist
}
fun activateGenre(genre: Genre?) {
if (genre == currentGenre) return
activateImpl(differ.currentList, currentGenre, genre)
currentGenre = genre
}
companion object { companion object {
private val DIFFER = private val DIFFER =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {

View file

@ -112,7 +112,8 @@ class SearchFragment :
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collectImmediately(searchModel.searchResults, ::handleResults) collectImmediately(searchModel.searchResults, ::handleResults)
collectImmediately(playbackModel.song, playbackModel.parent, ::handlePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::handlePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem, ::handleNavigation)
} }
@ -165,34 +166,8 @@ class SearchFragment :
binding.searchRecycler.isInvisible = results.isEmpty() binding.searchRecycler.isInvisible = results.isEmpty()
} }
private fun handlePlayback(song: Song?, parent: MusicParent?) { private fun handlePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent == null) { searchAdapter.updateIndicator(parent ?: song, isPlaying)
searchAdapter.activateSong(song)
} else {
// Ignore playback not from all songs
searchAdapter.activateSong(null)
}
if (parent is Album) {
searchAdapter.activateAlbum(parent)
} else {
// Ignore playback not from albums
searchAdapter.activateAlbum(null)
}
if (parent is Artist) {
searchAdapter.activateArtist(parent)
} else {
// Ignore playback not from artists
searchAdapter.activateArtist(null)
}
if (parent is Genre) {
searchAdapter.activateGenre(parent)
} else {
// Ignore playback not from artists
searchAdapter.activateGenre(null)
}
} }
private fun handleNavigation(item: Music?) { private fun handleNavigation(item: Music?) {

View file

@ -173,42 +173,84 @@ abstract class SimpleItemCallback<T : Item> : DiffUtil.ItemCallback<T>() {
} }
} }
abstract class ActivationAdapter<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() { // TODO: Base adapter that automates current list stuff for span size lookup
// TODO: Dialog view holder that automates the dumb sizing hack I have to do
abstract class IndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
private var isPlaying = false
private var currentItem: Item? = null
override fun onBindViewHolder(holder: VH, position: Int) = throw UnsupportedOperationException() override fun onBindViewHolder(holder: VH, position: Int) = throw UnsupportedOperationException()
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) { override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
holder.itemView.isActivated = shouldActivateViewHolder(position) if (holder is IndicatorViewHolder) {
val item = currentList[position]
val currentItem = currentItem
holder.updateIndicator(
currentItem != null &&
item.javaClass == currentItem.javaClass &&
item.id == currentItem.id,
isPlaying)
}
} }
protected abstract fun shouldActivateViewHolder(position: Int): Boolean abstract val currentList: List<Item>
fun updateIndicator(item: Item?, isPlaying: Boolean) {
var updatedItem = false
if (currentItem != item) {
val oldItem = currentItem
currentItem = item
protected inline fun <reified T : Item> activateImpl(
currentList: List<Item>,
oldItem: T?,
newItem: T?
) {
if (oldItem != null) { if (oldItem != null) {
val pos = currentList.indexOfFirst { item -> item.id == oldItem.id && item is T } val pos =
currentList.indexOfFirst {
it.javaClass == oldItem.javaClass && it.id == oldItem.id
}
if (pos > -1) { if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_ACTIVATION_CHANGED) notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED)
} else { } else {
logW("oldItem was not in adapter data") logW("oldItem was not in adapter data")
} }
} }
if (newItem != null) { if (item != null) {
val pos = currentList.indexOfFirst { item -> item is T && item.id == newItem.id } val pos =
currentList.indexOfFirst { it.javaClass == item.javaClass && it.id == item.id }
if (pos > -1) { if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_ACTIVATION_CHANGED) notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED)
} else {
logW("newItem was not in adapter data")
}
}
updatedItem = true
}
if (this.isPlaying != isPlaying) {
this.isPlaying = isPlaying
if (!updatedItem && item != null) {
val pos =
currentList.indexOfFirst { it.javaClass == item.javaClass && it.id == item.id }
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED)
} else { } else {
logW("newItem was not in adapter data") logW("newItem was not in adapter data")
} }
} }
} }
}
companion object { companion object {
val PAYLOAD_ACTIVATION_CHANGED = Any() val PAYLOAD_INDICATOR_CHANGED = Any()
} }
} }
abstract class IndicatorViewHolder(root: View) : RecyclerView.ViewHolder(root) {
abstract fun updateIndicator(isActive: Boolean, isPlaying: Boolean)
}

View file

@ -37,7 +37,7 @@ import org.oxycblt.auxio.util.inflater
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SongViewHolder private constructor(private val binding: ItemSongBinding) : class SongViewHolder private constructor(private val binding: ItemSongBinding) :
RecyclerView.ViewHolder(binding.root) { IndicatorViewHolder(binding.root) {
fun bind(item: Song, listener: MenuItemListener) { fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bind(item) binding.songAlbumCover.bind(item)
binding.songName.text = item.resolveName(binding.context) binding.songName.text = item.resolveName(binding.context)
@ -50,6 +50,11 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
binding.root.setOnClickListener { listener.onItemClick(item) } binding.root.setOnClickListener { listener.onItemClick(item) }
} }
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.songAlbumCover.isPlaying = isPlaying
}
companion object { companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SONG const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SONG
@ -71,7 +76,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
class AlbumViewHolder class AlbumViewHolder
private constructor( private constructor(
private val binding: ItemParentBinding, private val binding: ItemParentBinding,
) : RecyclerView.ViewHolder(binding.root) { ) : IndicatorViewHolder(binding.root) {
fun bind(item: Album, listener: MenuItemListener) { fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bind(item) binding.parentImage.bind(item)
@ -85,6 +90,11 @@ private constructor(
binding.root.setOnClickListener { listener.onItemClick(item) } binding.root.setOnClickListener { listener.onItemClick(item) }
} }
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.parentImage.isPlaying = isPlaying
}
companion object { companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM
@ -105,7 +115,7 @@ private constructor(
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) : class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
RecyclerView.ViewHolder(binding.root) { IndicatorViewHolder(binding.root) {
fun bind(item: Artist, listener: MenuItemListener) { fun bind(item: Artist, listener: MenuItemListener) {
binding.parentImage.bind(item) binding.parentImage.bind(item)
@ -123,6 +133,11 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
binding.root.setOnClickListener { listener.onItemClick(item) } binding.root.setOnClickListener { listener.onItemClick(item) }
} }
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.parentImage.isPlaying = isPlaying
}
companion object { companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST
@ -145,7 +160,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
class GenreViewHolder class GenreViewHolder
private constructor( private constructor(
private val binding: ItemParentBinding, private val binding: ItemParentBinding,
) : RecyclerView.ViewHolder(binding.root) { ) : IndicatorViewHolder(binding.root) {
fun bind(item: Genre, listener: MenuItemListener) { fun bind(item: Genre, listener: MenuItemListener) {
binding.parentImage.bind(item) binding.parentImage.bind(item)
@ -160,6 +175,11 @@ private constructor(
binding.root.setOnClickListener { listener.onItemClick(item) } binding.root.setOnClickListener { listener.onItemClick(item) }
} }
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.parentImage.isPlaying = isPlaying
}
companion object { companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE

View file

@ -144,6 +144,18 @@ fun <T1, T2> Fragment.collectImmediately(
launch { combine.collect { block(it.first, it.second) } } launch { combine.collect { block(it.first, it.second) } }
} }
/** Like [collectImmediately], but with three [StateFlow] values. */
fun <T1, T2, T3> Fragment.collectImmediately(
a: StateFlow<T1>,
b: StateFlow<T2>,
c: StateFlow<T3>,
block: (T1, T2, T3) -> Unit
) {
block(a.value, b.value, c.value)
val combine = combine(a, b, c) { a1, b2, c3 -> Triple(a1, b2, c3) }
launch { combine.collect { block(it.first, it.second, it.third) } }
}
/** /**
* Launches [block] in a lifecycle-aware coroutine once [state] is reached. This is primarily a * Launches [block] in a lifecycle-aware coroutine once [state] is reached. This is primarily a
* shortcut intended to correctly launch a co-routine on a fragment in a way that won't cause * shortcut intended to correctly launch a co-routine on a fragment in a way that won't cause

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,20V12H8V20ZM10,20V4H14V20ZM16,20V9H20V20Z" />
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M 3.9997559 17.999935 L 3.9997559 19.999813 L 8.0000285 19.999813 L 8.0000285 17.999935 L 3.9997559 17.999935 z M 9.9999064 17.999935 L 9.9999064 19.999813 L 14.000179 19.999813 L 14.000179 17.999935 L 9.9999064 17.999935 z M 16.000057 17.999935 L 16.000057 19.999813 L 19.999813 19.999813 L 19.999813 17.999935 L 16.000057 17.999935 z " />
</vector>

View file

@ -0,0 +1,492 @@
<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<!--
Yes, this whole file is all 30 frames of Spotify's equalizer animation
merged with the material equalizer icon, with each vector inlrined using
aapt:attr so that it does not clutter the drawable folder.
-->
<!-- Frame 1 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 2 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,3.9997559 c 0,5.3333524 0,10.6667051 0,16.0000571 1.3334246,0 2.6668486,0 4.0002726,0 0,-5.333352 0,-10.6667047 0,-16.0000571 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,10.999845 c 0,2.999989 0,5.999979 0,8.999968 1.3334242,0 2.6668484,0 4.0002726,0 0,-2.999989 0,-5.999979 0,-8.999968 -1.3334242,0 -2.6668484,0 -4.0002726,0 z m 12.0003011,0 c 0,2.999989 0,5.999979 0,8.999968 1.333252,0 2.666504,0 3.999756,0 0,-2.999989 0,-5.999979 0,-8.999968 -1.333252,0 -2.666504,0 -3.999756,0 z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 3 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,5.0002116 c 0,4.9998674 0,9.9997344 0,14.9996014 1.3334246,0 2.6668486,0 4.0002726,0 0,-4.999867 0,-9.999734 0,-14.9996014 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,9.9999064 c 0,3.3333026 0,6.6666046 0,9.9999066 1.3334242,0 2.6668484,0 4.0002726,0 0,-3.333302 0,-6.666604 0,-9.9999066 -1.3334242,0 -2.6668484,0 -4.0002726,0 z M 16.000057,14.000179 c 0,1.999878 0,3.999756 0,5.999634 1.333252,0 2.666504,0 3.999756,0 0,-1.999878 0,-3.999756 0,-5.999634 -1.333252,0 -2.666504,0 -3.999756,0 z " />
</vector>
</aapt:attr>
</item>
<!-- Frame 4 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,5.0002116 c 0,4.9998674 0,9.9997344 0,14.9996014 1.3334246,0 2.6668486,0 4.0002726,0 0,-4.999867 0,-9.999734 0,-14.9996014 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,9 c 0,3.666604 0,7.333209 0,10.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-3.666604 0,-7.333209 0,-10.999813 C 6.6666043,9 5.3331801,9 3.9997559,9 Z m 12.0003011,7 c 0,1.333271 0,2.666542 0,3.999813 1.333252,0 2.666504,0 3.999756,0 0,-1.333271 0,-2.666542 0,-3.999813 -1.333252,0 -2.666504,0 -3.999756,0 z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 5 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,7 c 0,4.333271 0,8.666542 0,12.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-4.333271 0,-8.666542 0,-12.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z m -6.0001505,4 c 0,2.999938 0,5.999875 0,8.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-2.999938 0,-5.999875 0,-8.999813 -1.3334242,0 -2.6668484,0 -4.0002726,0 z m 12.0003011,6 c 0,0.999938 0,1.999875 0,2.999813 1.333252,0 2.666504,0 3.999756,0 0,-0.999938 0,-1.999875 0,-2.999813 -1.333252,0 -2.666504,0 -3.999756,0 z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 6 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,9 c 0,3.666604 0,7.333209 0,10.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-3.666604 0,-7.333209 0,-10.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z m -6.0001505,1 c 0,3.333271 0,6.666542 0,9.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-3.333271 0,-6.666542 0,-9.999813 -1.3334242,0 -2.6668484,0 -4.0002726,0 z M 16,18 c 0.0000190,0.666604 0.0000380,1.333209 0.0000570,1.999813 1.333252,0 2.666504,0 3.999756,0 0,-0.666604 0,-1.333209 0,-1.999813 C 18.666542,18 17.333271,18 16,18 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 7 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,11 c 0,2.999938 0,5.999875 0,8.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-2.999938 0,-5.999875 0,-8.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,9 c 0,3.666604 0,7.333209 0,10.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-3.666604 0,-7.333209 0,-10.999813 C 6.6666043,9 5.3331801,9 3.9997559,9 Z M 16,18 c 0.0000190,0.666604 0.0000380,1.333209 0.0000570,1.999813 1.333252,0 2.666504,0 3.999756,0 0,-0.666604 0,-1.333209 0,-1.999813 C 18.666542,18 17.333271,18 16,18 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 8 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,13 c 0,2.333271 0,4.666542 0,6.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-2.333271 0,-4.666542 0,-6.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,8 c 0,3.999938 0,7.999875 0,11.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-3.999938 0,-7.999875 0,-11.999813 C 6.6666043,8 5.3331801,8 3.9997559,8 Z M 16,18 c 0.0000190,0.666604 0.0000380,1.333209 0.0000570,1.999813 1.333252,0 2.666504,0 3.999756,0 0,-0.666604 0,-1.333209 0,-1.999813 C 18.666542,18 17.333271,18 16,18 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 9 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,16 c 0,1.333271 0,2.666542 0,3.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-1.333271 0,-2.666542 0,-3.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,7 c 0,4.333271 0,8.666542 0,12.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-4.333271 0,-8.666542 0,-12.999813 C 6.6666043,7 5.3331801,7 3.9997559,7 Z M 16,17 c 0.0000190,0.999938 0.0000380,1.999875 0.0000570,2.999813 1.333252,0 2.666504,0 3.999756,0 0,-0.999938 0,-1.999875 0,-2.999813 C 18.666542,17 17.333271,17 16,17 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 10 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,17 c 0,0.999938 0,1.999875 0,2.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-0.999938 0,-1.999875 0,-2.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,6 c 0,4.666604 0,9.333209 0,13.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-4.666604 0,-9.333209 0,-13.999813 C 6.6666043,6 5.3331801,6 3.9997559,6 Z M 16,17 c 0.0000190,0.999938 0.0000380,1.999875 0.0000570,2.999813 1.333252,0 2.666504,0 3.999756,0 0,-0.999938 0,-1.999875 0,-2.999813 C 18.666542,17 17.333271,17 16,17 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 11 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,18 c 0,0.666604 0,1.333209 0,1.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-0.666604 0,-1.333209 0,-1.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,5 c 0,4.9999377 0,9.999875 0,14.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 6.6666043,5 5.3331801,5 3.9997559,5 Z M 16,15 c 0.0000190,1.666604 0.0000380,3.333209 0.0000570,4.999813 1.333252,0 2.666504,0 3.999756,0 0,-1.666604 0,-3.333209 0,-4.999813 C 18.666542,15 17.333271,15 16,15 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 12 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,18 c 0,0.666604 0,1.333209 0,1.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-0.666604 0,-1.333209 0,-1.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,5 c 0,4.9999377 0,9.999875 0,14.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 6.6666043,5 5.3331801,5 3.9997559,5 Z M 16,14 c 0.0000190,1.999938 0.0000380,3.999875 0.0000570,5.999813 1.333252,0 2.666504,0 3.999756,0 0,-1.999938 0,-3.999875 0,-5.999813 C 18.666542,14 17.333271,14 16,14 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 13 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,17 c 0,0.999938 0,1.999875 0,2.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-0.999938 0,-1.999875 0,-2.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,4 c 0,5.333271 0,10.666542 0,15.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-5.333271 0,-10.666542 0,-15.999813 C 6.6666043,4 5.3331801,4 3.9997559,4 Z M 16,12 c 0.0000190,2.666604 0.0000380,5.333209 0.0000570,7.999813 1.333252,0 2.666504,0 3.999756,0 0,-2.666604 0,-5.333209 0,-7.999813 C 18.666542,12 17.333271,12 16,12 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 14 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,17 c 0,0.999938 0,1.999875 0,2.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-0.999938 0,-1.999875 0,-2.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,4 c 0,5.333271 0,10.666542 0,15.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-5.333271 0,-10.666542 0,-15.999813 C 6.6666043,4 5.3331801,4 3.9997559,4 Z M 16,11 c 0.0000190,2.999938 0.0000380,5.999875 0.0000570,8.999813 1.333252,0 2.666504,0 3.999756,0 0,-2.999938 0,-5.999875 0,-8.999813 C 18.666542,11 17.333271,11 16,11 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 15 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,17 c 0,0.999938 0,1.999875 0,2.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-0.999938 0,-1.999875 0,-2.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,4 c 0,5.333271 0,10.666542 0,15.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-5.333271 0,-10.666542 0,-15.999813 C 6.6666043,4 5.3331801,4 3.9997559,4 Z M 16,10 c 0.0000190,3.333271 0.0000380,6.666542 0.0000570,9.999813 1.333252,0 2.666504,0 3.999756,0 0,-3.333271 0,-6.666542 0,-9.999813 C 18.666542,10 17.333271,10 16,10 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 16 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,15 c 0,1.666604 0,3.333209 0,4.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-1.666604 0,-3.333209 0,-4.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,5 c 0,4.9999377 0,9.999875 0,14.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 6.6666043,5 5.3331801,5 3.9997559,5 Z M 16,10 c 0.0000190,3.333271 0.0000380,6.666542 0.0000570,9.999813 1.333252,0 2.666504,0 3.999756,0 0,-3.333271 0,-6.666542 0,-9.999813 C 18.666542,10 17.333271,10 16,10 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 17 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,14 c 0,1.999938 0,3.999875 0,5.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-1.999938 0,-3.999875 0,-5.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,5 c 0,4.9999377 0,9.999875 0,14.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 6.6666043,5 5.3331801,5 3.9997559,5 Z M 16,9 c 0.0000190,3.666604 0.0000380,7.333209 0.0000570,10.999813 1.333252,0 2.666504,0 3.999756,0 0,-3.666604 0,-7.333209 0,-10.999813 C 18.666542,9 17.333271,9 16,9 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 18 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,14 c 0,1.999938 0,3.999875 0,5.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-1.999938 0,-3.999875 0,-5.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 3.9997559,5 c 0,4.9999377 0,9.999875 0,14.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 6.6666043,5 5.3331801,5 3.9997559,5 Z M 16,9 c 0.0000190,3.666604 0.0000380,7.333209 0.0000570,10.999813 1.333252,0 2.666504,0 3.999756,0 0,-3.666604 0,-7.333209 0,-10.999813 C 18.666542,9 17.333271,9 16,9 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 19 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 13.999663,10 c 0,3.333271 0,6.666542 0,9.999813 -1.333424,0 -2.666849,0 -4.0002731,0 0,-3.333271 0,-6.666542 0,-9.999813 1.3334241,0 2.6668491,0 4.0002731,0 z m 5.999906,0 c 0.0000810,3.333271 0.0001630,6.666542 0.0002440,9.999813 -1.333424,0 -2.666849,0 -4.000273,0 0,-3.333271 0,-6.666542 0,-9.999813 1.333343,0 2.666686,0 4.000029,0 z M 7.9995689,9 c -0.0000190,3.666604 -0.0000380,7.333209 -0.0000570,10.999813 -1.333252,0 -2.666504,0 -3.999756,0 0,-3.666604 0,-7.333209 0,-10.999813 1.333271,0 2.666542,0 3.999813,0 z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 20 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,10 c 0,3.333271 0,6.666542 0,9.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-3.333271 0,-6.666542 0,-9.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 4,13 c -0.0000814,2.333271 -0.0001627,4.666542 -0.0002441,6.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-2.333271 0,-4.666542 0,-6.999813 C 6.6666857,13 5.3333428,13 4,13 Z M 16,9 c 0.0000190,3.666604 0.0000380,7.333209 0.0000570,10.999813 1.333252,0 2.666504,0 3.999756,0 0,-3.666604 0,-7.333209 0,-10.999813 C 18.666542,9 17.333271,9 16,9 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 21 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,9 c 0,3.666604 0,7.333209 0,10.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-3.666604 0,-7.333209 0,-10.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 4,15 c -0.0000814,1.666604 -0.0001627,3.333209 -0.0002441,4.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-1.666604 0,-3.333209 0,-4.999813 C 6.6666857,15 5.3333428,15 4,15 Z M 16,8 c 0.0000190,3.999938 0.0000380,7.999875 0.0000570,11.999813 1.333252,0 2.666504,0 3.999756,0 0,-3.999938 0,-7.999875 0,-11.999813 C 18.666542,8 17.333271,8 16,8 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 22 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,10 c 0,3.333271 0,6.666542 0,9.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-3.333271 0,-6.666542 0,-9.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 4,17 c -0.0000814,0.999938 -0.0001627,1.999875 -0.0002441,2.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-0.999938 0,-1.999875 0,-2.999813 C 6.6666857,17 5.3333428,17 4,17 Z M 16,7 c 0.0000190,4.333271 0.0000380,8.666542 0.0000570,12.999813 1.333252,0 2.666504,0 3.999756,0 0,-4.333271 0,-8.666542 0,-12.999813 C 18.666542,7 17.333271,7 16,7 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 23 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,9 c 0,3.666604 0,7.333209 0,10.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-3.666604 0,-7.333209 0,-10.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 4,17 c -0.0000814,0.999938 -0.0001627,1.999875 -0.0002441,2.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-0.999938 0,-1.999875 0,-2.999813 C 6.6666857,17 5.3333428,17 4,17 Z M 16,6 c 0.0000190,4.666604 0.0000380,9.333209 0.0000570,13.999813 1.333252,0 2.666504,0 3.999756,0 0,-4.666604 0,-9.333209 0,-13.999813 C 18.666542,6 17.333271,6 16,6 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 24 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,9 c 0,3.666604 0,7.333209 0,10.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-3.666604 0,-7.333209 0,-10.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 4,18 c -0.0000814,0.666604 -0.0001627,1.333209 -0.0002441,1.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-0.666604 0,-1.333209 0,-1.999813 C 6.6666857,18 5.3333428,18 4,18 Z M 16,5 c 0.0000190,4.9999377 0.0000380,9.999875 0.0000570,14.999813 1.333252,0 2.666504,0 3.999756,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 18.666542,5 17.333271,5 16,5 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 25 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,9 c 0,3.666604 0,7.333209 0,10.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-3.666604 0,-7.333209 0,-10.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 4,18 c -0.0000814,0.666604 -0.0001627,1.333209 -0.0002441,1.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-0.666604 0,-1.333209 0,-1.999813 C 6.6666857,18 5.3333428,18 4,18 Z M 16,5 c 0.0000190,4.9999377 0.0000380,9.999875 0.0000570,14.999813 1.333252,0 2.666504,0 3.999756,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 18.666542,5 17.333271,5 16,5 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 26 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 9.9999064,8 c 0,3.999938 0,7.999875 0,11.999813 1.3334246,0 2.6668486,0 4.0002726,0 0,-3.999938 0,-7.999875 0,-11.999813 -1.333424,0 -2.666848,0 -4.0002726,0 z M 4,17 c -0.0000814,0.999938 -0.0001627,1.999875 -0.0002441,2.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-0.999938 0,-1.999875 0,-2.999813 C 6.6666857,17 5.3333428,17 4,17 Z M 16,4 c 0.0000190,5.333271 0.0000380,10.666542 0.0000570,15.999813 1.333252,0 2.666504,0 3.999756,0 0,-5.333271 0,-10.666542 0,-15.999813 C 18.666542,4 17.333271,4 16,4 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 27 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 10,7 c -0.0000312,4.333271 -0.0000624,8.666542 -0.0000936,12.999813 1.3334246,0 2.6668486,0 4.0002726,0 C 14.000119,15.666542 14.00006,11.333271 14,7 12.666667,7 11.333333,7 10,7 Z M 4,17 c -0.0000814,0.999938 -0.0001627,1.999875 -0.0002441,2.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-0.999938 0,-1.999875 0,-2.999813 C 6.6666857,17 5.3333428,17 4,17 Z M 16,4 c 0.0000190,5.333271 0.0000380,10.666542 0.0000570,15.999813 1.333252,0 2.666504,0 3.999756,0 0,-5.333271 0,-10.666542 0,-15.999813 C 18.666542,4 17.333271,4 16,4 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 28 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 10,6 c -0.0000312,4.666604 -0.0000624,9.333209 -0.0000936,13.999813 1.3334246,0 2.6668486,0 4.0002726,0 C 14.000119,15.333209 14.00006,10.666604 14,6 12.666667,6 11.333333,6 10,6 Z M 4,16 c -0.0000814,1.333271 -0.0001627,2.666542 -0.0002441,3.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-1.333271 0,-2.666542 0,-3.999813 C 6.6666857,16 5.3333428,16 4,16 Z M 16,5 c 0.0000190,4.9999377 0.0000380,9.999875 0.0000570,14.999813 1.333252,0 2.666504,0 3.999756,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 18.666542,5 17.333271,5 16,5 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 29 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 10,5 c -0.0000312,4.9999377 -0.0000624,9.999875 -0.0000936,14.999813 1.3334246,0 2.6668486,0 4.0002726,0 C 14.000119,14.999875 14.00006,9.9999377 14,5 12.666667,5 11.333333,5 10,5 Z m -6,9 c -0.0000814,1.999938 -0.0001627,3.999875 -0.0002441,5.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-1.999938 0,-3.999875 0,-5.999813 C 6.6666857,14 5.3333428,14 4,14 Z M 16,5 c 0.0000190,4.9999377 0.0000380,9.999875 0.0000570,14.999813 1.333252,0 2.666504,0 3.999756,0 0,-4.999938 0,-9.9998753 0,-14.999813 C 18.666542,5 17.333271,5 16,5 Z" />
</vector>
</aapt:attr>
</item>
<!-- Frame 30 -->
<item android:duration="30">
<aapt:attr name="android:drawable">
<vector
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="m 10,5 c -0.0000312,4.9999377 -0.0000624,9.999875 -0.0000936,14.999813 1.3334246,0 2.6668486,0 4.0002726,0 C 14.000119,14.999875 14.00006,9.9999377 14,5 12.666667,5 11.333333,5 10,5 Z m -6,8 c -0.0000814,2.333271 -0.0001627,4.666542 -0.0002441,6.999813 1.3334242,0 2.6668484,0 4.0002726,0 0,-2.333271 0,-4.666542 0,-6.999813 C 6.6666857,13 5.3333428,13 4,13 Z M 16,6 c 0.0000190,4.666604 0.0000380,9.333209 0.0000570,13.999813 1.333252,0 2.666504,0 3.999756,0 0,-4.666604 0,-9.333209 0,-13.999813 C 18.666542,6 17.333271,6 16,6 Z" />
</vector>
</aapt:attr>
</item>
</animation-list>