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
#### 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
- Queue now has a fast scroller

View file

@ -69,6 +69,8 @@ dependencies {
// --- SUPPORT ---
// 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.activity:activity-ktx:1.6.0-rc01"
implementation "androidx.fragment:fragment-ktx:1.5.2"

View file

@ -96,7 +96,8 @@ class AlbumDetailFragment :
collectImmediately(detailModel.currentAlbum, ::handleItemChange)
collectImmediately(detailModel.albumData, detailAdapter::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
@ -135,6 +136,7 @@ class AlbumDetailFragment :
override fun onOpenMenu(item: Item, anchor: View) {
if (item is Song) {
musicMenu(anchor, R.menu.menu_album_song_actions, item)
return
}
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()
for (item in binding.detailToolbar.menu.children) {
@ -257,10 +259,10 @@ class AlbumDetailFragment :
}
if (parent is Album && parent.id == unlikelyToBeNull(detailModel.currentAlbum.value).id) {
detailAdapter.activateSong(song)
detailAdapter.updateIndicator(song, isPlaying)
} else {
// 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.artistData, detailAdapter::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
@ -200,20 +201,17 @@ class ArtistDetailFragment :
}
}
private fun updatePlayback(song: Song?, parent: MusicParent?) {
if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
detailAdapter.activateSong(song)
} else {
// Ignore song playback not from the artist
detailAdapter.activateSong(null)
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
var item: Item? = null
if (parent is Album) {
item = parent
}
if (parent is Album &&
parent.artist.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
detailAdapter.activateAlbum(parent)
} else {
// Ignore album playback not from the artist
detailAdapter.activateAlbum(null)
if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
item = song
}
detailAdapter.updateIndicator(item, isPlaying)
}
}

View file

@ -92,7 +92,8 @@ class GenreDetailFragment :
collectImmediately(detailModel.currentGenre, ::handleItemChange)
collectImmediately(detailModel.genreData, detailAdapter::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
@ -129,10 +130,11 @@ class GenreDetailFragment :
}
override fun onOpenMenu(item: Item, anchor: View) {
when (item) {
is Song -> musicMenu(anchor, R.menu.menu_song_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
if (item is Song) {
musicMenu(anchor, R.menu.menu_song_actions, item)
}
error("Unexpected datatype when opening menu: ${item::class.java}")
}
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) {
detailAdapter.activateSong(song)
detailAdapter.updateIndicator(song, isPlaying)
} else {
// 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.music.Album
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.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
@ -43,7 +44,6 @@ import org.oxycblt.auxio.util.inflater
*/
class AlbumDetailAdapter(private val listener: Listener) :
DetailAdapter<AlbumDetailAdapter.Listener>(listener, DIFFER) {
private var currentSong: Song? = null
override fun getItemViewType(position: Int) =
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 {
private val DIFFER =
object : SimpleItemCallback<Item>() {
@ -182,7 +170,7 @@ class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
}
private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) :
RecyclerView.ViewHolder(binding.root) {
IndicatorViewHolder(binding.root) {
fun bind(item: Song, listener: MenuItemListener) {
// Hide the track number view if the song does not have a track.
if (item.track != null) {
@ -210,6 +198,11 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
binding.root.setOnClickListener { listener.onItemClick(item) }
}
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.songTrackBg.isPlaying = isPlaying
}
companion object {
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.resolveYear
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.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
@ -44,8 +45,6 @@ import org.oxycblt.auxio.util.inflater
*/
class ArtistDetailAdapter(private val listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentAlbum: Album? = null
private var currentSong: Song? = null
override fun getItemViewType(position: Int) =
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 {
private val DIFFER =
object : SimpleItemCallback<Item>() {
@ -158,7 +137,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
private class ArtistAlbumViewHolder
private constructor(
private val binding: ItemParentBinding,
) : RecyclerView.ViewHolder(binding.root) {
) : IndicatorViewHolder(binding.root) {
fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bind(item)
binding.parentName.text = item.resolveName(binding.context)
@ -171,6 +150,11 @@ private constructor(
binding.root.setOnClickListener { listener.onItemClick(item) }
}
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.parentImage.isPlaying = isPlaying
}
companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_ALBUM
@ -188,7 +172,7 @@ private constructor(
private class ArtistSongViewHolder
private constructor(
private val binding: ItemSongBinding,
) : RecyclerView.ViewHolder(binding.root) {
) : IndicatorViewHolder(binding.root) {
fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bind(item)
binding.songName.text = item.resolveName(binding.context)
@ -201,6 +185,11 @@ private constructor(
binding.root.setOnClickListener { listener.onItemClick(item) }
}
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.songAlbumCover.isPlaying = isPlaying
}
companion object {
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.databinding.ItemSortHeaderBinding
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.HeaderViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
@ -38,7 +38,9 @@ import org.oxycblt.auxio.util.inflater
abstract class DetailAdapter<L : DetailAdapter.Listener>(
private val listener: L,
diffCallback: DiffUtil.ItemCallback<Item>
) : ActivationAdapter<RecyclerView.ViewHolder>() {
) : IndicatorAdapter<RecyclerView.ViewHolder>() {
private var isPlaying = false
@Suppress("LeakingThis") override fun getItemCount() = differ.currentList.size
override fun getItemViewType(position: Int) =
@ -77,7 +79,7 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
protected val differ = AsyncListDiffer(this, diffCallback)
val currentList: List<Item>
override val currentList: List<Item>
get() = differ.currentList
fun submitList(list: List<Item>) {

View file

@ -40,6 +40,7 @@ import org.oxycblt.auxio.util.inflater
class GenreDetailAdapter(private val listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentSong: Song? = null
private var isPlaying = false
override fun getItemViewType(position: Int) =
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 {
val DIFFER =
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.ui.DisplayMode
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.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
@ -56,7 +56,7 @@ class AlbumListFragment : HomeListFragment<Album>() {
}
collectImmediately(homeModel.albums, homeAdapter::replaceList)
collectImmediately(playbackModel.parent, ::handleParent)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handleParent)
}
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) {
homeAdapter.activateAlbum(parent)
homeAdapter.updateIndicator(parent, isPlaying)
} else {
// Ignore playback not from albums
homeAdapter.activateAlbum(null)
homeAdapter.updateIndicator(null, isPlaying)
}
}
private class AlbumAdapter(private val listener: MenuItemListener) :
ActivationAdapter<AlbumViewHolder>() {
IndicatorAdapter<AlbumViewHolder>() {
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
@ -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>) {
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.ui.DisplayMode
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.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
@ -51,7 +51,7 @@ class ArtistListFragment : HomeListFragment<Artist>() {
}
collectImmediately(homeModel.artists, homeAdapter::replaceList)
collectImmediately(playbackModel.parent, ::handleParent)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handleParent)
}
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) {
homeAdapter.activateArtist(parent)
homeAdapter.updateIndicator(parent, isPlaying)
} else {
// Ignore playback not from artists
homeAdapter.activateArtist(null)
homeAdapter.updateIndicator(null, isPlaying)
}
}
private class ArtistAdapter(private val listener: MenuItemListener) :
ActivationAdapter<ArtistViewHolder>() {
IndicatorAdapter<ArtistViewHolder>() {
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
@ -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>) {
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.ui.DisplayMode
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.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
@ -51,7 +51,7 @@ class GenreListFragment : HomeListFragment<Genre>() {
}
collectImmediately(homeModel.genres, homeAdapter::replaceList)
collectImmediately(playbackModel.parent, ::handlePlayback)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handlePlayback)
}
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) {
homeAdapter.activateGenre(parent)
homeAdapter.updateIndicator(parent, isPlaying)
} else {
// Ignore playback not from genres
homeAdapter.activateGenre(null)
homeAdapter.updateIndicator(null, isPlaying)
}
}
private class GenreAdapter(private val listener: MenuItemListener) :
ActivationAdapter<GenreViewHolder>() {
IndicatorAdapter<GenreViewHolder>() {
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
@ -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>) {
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.ui.DisplayMode
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.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SongViewHolder
@ -58,7 +58,8 @@ class SongListFragment : HomeListFragment<Song>() {
}
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? {
@ -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) {
homeAdapter.activateSong(song)
homeAdapter.updateIndicator(song, isPlaying)
} else {
// Ignore playback that is not from all songs
homeAdapter.activateSong(null)
homeAdapter.updateIndicator(null, isPlaying)
}
}
private class SongAdapter(private val listener: MenuItemListener) :
ActivationAdapter<SongViewHolder>() {
IndicatorAdapter<SongViewHolder>() {
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
@ -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>) {
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.Song
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
/**
* Effectively a super-charged [StyledImageView].
@ -52,7 +51,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val cornerRadius: Float
private val inner: StyledImageView
private var customView: View? = null
private val indicator: StyledImageView
private val indicator: IndicatorView
init {
// 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()
inner = StyledImageView(context, attrs)
indicator =
StyledImageView(context).apply {
cornerRadius = this@ImageGroup.cornerRadius
staticIcon = context.getDrawableCompat(R.drawable.ic_currently_playing_24)
}
indicator = IndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius }
addView(inner)
}
@ -101,6 +96,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
invalidateIndicator()
}
fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {}
var isPlaying: Boolean
get() = indicator.isPlaying
set(value) {
indicator.isPlaying = value
}
override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled)
invalidateIndicator()
@ -109,14 +112,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private fun invalidateIndicator() {
if (isActivated) {
alpha = 1f
indicator.alpha = 1f
customView?.alpha = 0f
inner.alpha = 0f
indicator.alpha = 1f
} else {
alpha = if (isEnabled) 1f else 0.5f
indicator.alpha = 0f
customView?.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>() {
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFFER)
private var currentIndex = 0
private var isPlaying = false
override fun getItemCount() = differ.currentList.size
@ -54,7 +55,7 @@ class QueueAdapter(private val listener: QueueItemListener) :
}
viewHolder.isEnabled = position > currentIndex
viewHolder.isActivated = position == currentIndex
viewHolder.updateIndicator(position == currentIndex, isPlaying)
}
fun submitList(newList: List<Song>) {
@ -65,16 +66,30 @@ class QueueAdapter(private val listener: QueueItemListener) :
differ.replaceList(newList)
}
fun updateIndex(index: Int) {
when {
index < currentIndex -> {
val lastIndex = currentIndex
currentIndex = index
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_INDEX)
fun updateIndicator(index: Int, isPlaying: Boolean) {
var updatedIndex = false
if (index != currentIndex) {
when {
index < currentIndex -> {
val lastIndex = currentIndex
currentIndex = index
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_INDEX)
}
else -> {
currentIndex = index
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_INDEX)
}
}
index > currentIndex -> {
currentIndex = index
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_INDEX)
updatedIndex = true
}
if (this.isPlaying != isPlaying) {
this.isPlaying = isPlaying
if (!updatedIndex) {
notifyItemChanged(index, PAYLOAD_UPDATE_INDEX)
}
}
}
@ -92,7 +107,7 @@ interface QueueItemListener {
class QueueSongViewHolder
private constructor(
private val binding: ItemQueueSongBinding,
) : RecyclerView.ViewHolder(binding.root) {
) : IndicatorViewHolder(binding.root) {
val bodyView: View
get() = binding.body
val backgroundView: View
@ -146,12 +161,10 @@ private constructor(
binding.songDragHandle.isEnabled = value
}
var isActivated: Boolean
get() = binding.interactBody.isActivated
set(value) {
// Activation does not affect clicking, make everything activated.
binding.interactBody.isActivated = value
}
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.interactBody.isActivated = isActive
binding.songAlbumCover.isPlaying = isPlaying
}
companion object {
fun new(parent: View) =

View file

@ -27,7 +27,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
@ -38,6 +40,7 @@ import org.oxycblt.auxio.util.logD
*/
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemListener {
private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val queueAdapter = QueueAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject {
ItemTouchHelper(QueueDragCallback(queueModel))
@ -63,7 +66,8 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
// --- VIEWMODEL SETUP ----
collectImmediately(queueModel.queue, queueModel.index, ::updateQueue)
collectImmediately(
queueModel.queue, queueModel.index, playbackModel.isPlaying, ::updateQueue)
}
override fun onDestroyBinding(binding: FragmentQueueBinding) {
@ -79,7 +83,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
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 replaceQueue = queueModel.replaceQueue
@ -111,7 +115,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
queueModel.finishScrollTo()
queueAdapter.updateIndex(index)
queueAdapter.updateIndicator(index, isPlaying)
}
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.Genre
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.ArtistViewHolder
import org.oxycblt.auxio.ui.recycler.GenreViewHolder
import org.oxycblt.auxio.ui.recycler.Header
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.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
import org.oxycblt.auxio.ui.recycler.SongViewHolder
class SearchAdapter(private val listener: MenuItemListener) :
ActivationAdapter<RecyclerView.ViewHolder>() {
IndicatorAdapter<RecyclerView.ViewHolder>() {
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
@ -83,44 +79,11 @@ class SearchAdapter(private val listener: MenuItemListener) :
}
}
override fun shouldActivateViewHolder(position: Int): Boolean {
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>
override val currentList: List<Item>
get() = differ.currentList
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 {
private val DIFFER =
object : SimpleItemCallback<Item>() {

View file

@ -112,7 +112,8 @@ class SearchFragment :
// --- VIEWMODEL SETUP ---
collectImmediately(searchModel.searchResults, ::handleResults)
collectImmediately(playbackModel.song, playbackModel.parent, ::handlePlayback)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::handlePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
@ -165,34 +166,8 @@ class SearchFragment :
binding.searchRecycler.isInvisible = results.isEmpty()
}
private fun handlePlayback(song: Song?, parent: MusicParent?) {
if (parent == null) {
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 handlePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
searchAdapter.updateIndicator(parent ?: song, isPlaying)
}
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, 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>
protected inline fun <reified T : Item> activateImpl(
currentList: List<Item>,
oldItem: T?,
newItem: T?
) {
if (oldItem != null) {
val pos = currentList.indexOfFirst { item -> item.id == oldItem.id && item is T }
fun updateIndicator(item: Item?, isPlaying: Boolean) {
var updatedItem = false
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_ACTIVATION_CHANGED)
} else {
logW("oldItem was not in adapter data")
if (currentItem != item) {
val oldItem = currentItem
currentItem = item
if (oldItem != null) {
val pos =
currentList.indexOfFirst {
it.javaClass == oldItem.javaClass && it.id == oldItem.id
}
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED)
} else {
logW("oldItem was not in adapter data")
}
}
if (item != null) {
val pos =
currentList.indexOfFirst { it.javaClass == item.javaClass && it.id == item.id }
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED)
} else {
logW("newItem was not in adapter data")
}
}
updatedItem = true
}
if (newItem != null) {
val pos = currentList.indexOfFirst { item -> item is T && item.id == newItem.id }
if (this.isPlaying != isPlaying) {
this.isPlaying = isPlaying
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_ACTIVATION_CHANGED)
} else {
logW("newItem was not in adapter data")
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 {
logW("newItem was not in adapter data")
}
}
}
}
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
*/
class SongViewHolder private constructor(private val binding: ItemSongBinding) :
RecyclerView.ViewHolder(binding.root) {
IndicatorViewHolder(binding.root) {
fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bind(item)
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) }
}
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.songAlbumCover.isPlaying = isPlaying
}
companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SONG
@ -71,7 +76,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
class AlbumViewHolder
private constructor(
private val binding: ItemParentBinding,
) : RecyclerView.ViewHolder(binding.root) {
) : IndicatorViewHolder(binding.root) {
fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bind(item)
@ -85,6 +90,11 @@ private constructor(
binding.root.setOnClickListener { listener.onItemClick(item) }
}
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.parentImage.isPlaying = isPlaying
}
companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM
@ -105,7 +115,7 @@ private constructor(
* @author OxygenCobalt
*/
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
RecyclerView.ViewHolder(binding.root) {
IndicatorViewHolder(binding.root) {
fun bind(item: Artist, listener: MenuItemListener) {
binding.parentImage.bind(item)
@ -123,6 +133,11 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
binding.root.setOnClickListener { listener.onItemClick(item) }
}
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.parentImage.isPlaying = isPlaying
}
companion object {
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST
@ -145,7 +160,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
class GenreViewHolder
private constructor(
private val binding: ItemParentBinding,
) : RecyclerView.ViewHolder(binding.root) {
) : IndicatorViewHolder(binding.root) {
fun bind(item: Genre, listener: MenuItemListener) {
binding.parentImage.bind(item)
@ -160,6 +175,11 @@ private constructor(
binding.root.setOnClickListener { listener.onItemClick(item) }
}
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive
binding.parentImage.isPlaying = isPlaying
}
companion object {
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) } }
}
/** 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
* 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>