Add shuffling

Add basic shuffling to PlaybackFragment.
This commit is contained in:
OxygenCobalt 2020-10-15 11:31:31 -06:00
parent 339100e436
commit c422071e93
15 changed files with 190 additions and 78 deletions

View file

@ -8,6 +8,7 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.recycler.DiffCallback import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
// TODO: Add ability to highlight currently playing songs
class DetailAlbumAdapter( class DetailAlbumAdapter(
private val doOnClick: (Album) -> Unit private val doOnClick: (Album) -> Unit
) : ListAdapter<Album, DetailAlbumAdapter.ViewHolder>(DiffCallback()) { ) : ListAdapter<Album, DetailAlbumAdapter.ViewHolder>(DiffCallback()) {

View file

@ -11,7 +11,6 @@ import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
class DetailSongAdapter( class DetailSongAdapter(
private val doOnClick: (Song) -> Unit private val doOnClick: (Song) -> Unit
) : ListAdapter<Song, DetailSongAdapter.ViewHolder>(DiffCallback()) { ) : ListAdapter<Song, DetailSongAdapter.ViewHolder>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder( return ViewHolder(
ItemAlbumSongBinding.inflate(LayoutInflater.from(parent.context)) ItemAlbumSongBinding.inflate(LayoutInflater.from(parent.context))

View file

@ -22,7 +22,9 @@ class MusicStore private constructor() {
private var mSongs = listOf<Song>() private var mSongs = listOf<Song>()
val songs: List<Song> get() = mSongs val songs: List<Song> get() = mSongs
suspend fun load(app: Application): MusicLoaderResponse { // Load/Sort the entire library.
// ONLY CALL THIS FROM AN IO THREAD.
fun load(app: Application): MusicLoaderResponse {
Log.i(this::class.simpleName, "Starting initial music load...") Log.i(this::class.simpleName, "Starting initial music load...")
val start = System.currentTimeMillis() val start = System.currentTimeMillis()

View file

@ -25,7 +25,6 @@ class CompactPlaybackFragment : Fragment() {
): View? { ): View? {
val binding = FragmentCompactPlaybackBinding.inflate(inflater) val binding = FragmentCompactPlaybackBinding.inflate(inflater)
// FIXME: Stop these icons from self-animating on creation.
val iconPauseToPlay = ContextCompat.getDrawable( val iconPauseToPlay = ContextCompat.getDrawable(
requireContext(), R.drawable.ic_pause_to_play requireContext(), R.drawable.ic_pause_to_play
) as AnimatedVectorDrawable ) as AnimatedVectorDrawable

View file

@ -23,9 +23,7 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
// TODO: Implement media controls // TODO: Implement media controls
// TODO: Make exit icon bigger
// TODO: Implement nav to artists/albums // TODO: Implement nav to artists/albums
// TODO: Possibly implement a trackbar with a spectrum shown as well.
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -98,6 +96,15 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
} }
} }
playbackModel.isShuffling.observe(viewLifecycleOwner) {
// Highlight the shuffle button if Playback is shuffled, and revert it if not.
if (it) {
binding.playbackShuffle.imageTintList = accentColor
} else {
binding.playbackShuffle.imageTintList = controlColor
}
}
playbackModel.isSeeking.observe(viewLifecycleOwner) { playbackModel.isSeeking.observe(viewLifecycleOwner) {
// Highlight the current duration if the user is seeking, and revert it if not. // Highlight the current duration if the user is seeking, and revert it if not.
if (it) { if (it) {

View file

@ -7,12 +7,4 @@ package org.oxycblt.auxio.playback
// IN_ALBUM -> Play from the songs of the album // IN_ALBUM -> Play from the songs of the album
enum class PlaybackMode { enum class PlaybackMode {
IN_ARTIST, IN_ALBUM, ALL_SONGS; IN_ARTIST, IN_ALBUM, ALL_SONGS;
// Make a slice of all the values that this ShowMode covers.
// ex. SHOW_ARTISTS would return SHOW_ARTISTS, SHOW_ALBUMS, and SHOW_SONGS
fun getChildren(): List<PlaybackMode> {
val vals = values()
return vals.slice(vals.indexOf(this) until vals.size)
}
} }

View file

@ -8,10 +8,11 @@ import androidx.lifecycle.ViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toDuration import org.oxycblt.auxio.music.toDuration
import kotlin.random.Random
import kotlin.random.Random.Default.nextLong
// TODO: Implement media controls // TODO: Implement media controls
// TODO: Implement persistence // TODO: Implement persistence
@ -39,6 +40,11 @@ class PlaybackViewModel : ViewModel() {
private val mIsPlaying = MutableLiveData(false) private val mIsPlaying = MutableLiveData(false)
val isPlaying: LiveData<Boolean> get() = mIsPlaying val isPlaying: LiveData<Boolean> get() = mIsPlaying
private val mIsShuffling = MutableLiveData(false)
val isShuffling: LiveData<Boolean> get() = mIsShuffling
private val mShuffleSeed = MutableLiveData(-1L)
private val mIsSeeking = MutableLiveData(false) private val mIsSeeking = MutableLiveData(false)
val isSeeking: LiveData<Boolean> get() = mIsSeeking val isSeeking: LiveData<Boolean> get() = mIsSeeking
@ -57,6 +63,8 @@ class PlaybackViewModel : ViewModel() {
val musicStore = MusicStore.getInstance() val musicStore = MusicStore.getInstance()
mCurrentMode.value = mode
updatePlayback(song) updatePlayback(song)
mQueue.value = when (mode) { mQueue.value = when (mode) {
@ -65,7 +73,18 @@ class PlaybackViewModel : ViewModel() {
PlaybackMode.IN_ALBUM -> song.album.songs PlaybackMode.IN_ALBUM -> song.album.songs
} }
mCurrentMode.value = mode mCurrentParent.value = when (mode) {
PlaybackMode.ALL_SONGS -> null
PlaybackMode.IN_ARTIST -> song.album.artist
PlaybackMode.IN_ALBUM -> song.album
}
if (isShuffling.value!!) {
genShuffle(true)
} else {
resetShuffle()
}
mCurrentIndex.value = mQueue.value!!.indexOf(song) mCurrentIndex.value = mQueue.value!!.indexOf(song)
} }
@ -79,6 +98,7 @@ class PlaybackViewModel : ViewModel() {
mQueue.value = songs mQueue.value = songs
mCurrentIndex.value = 0 mCurrentIndex.value = 0
mCurrentParent.value = album mCurrentParent.value = album
mIsShuffling.value = isShuffled
mCurrentMode.value = PlaybackMode.IN_ALBUM mCurrentMode.value = PlaybackMode.IN_ALBUM
} }
@ -92,71 +112,49 @@ class PlaybackViewModel : ViewModel() {
mQueue.value = songs mQueue.value = songs
mCurrentIndex.value = 0 mCurrentIndex.value = 0
mCurrentParent.value = artist mCurrentParent.value = artist
mIsShuffling.value = isShuffled
mCurrentMode.value = PlaybackMode.IN_ARTIST mCurrentMode.value = PlaybackMode.IN_ARTIST
} }
fun play(genre: Genre, isShuffled: Boolean) {
Log.d(this::class.simpleName, "Playing genre ${genre.name}")
val songs = orderSongsInGenre(genre)
updatePlayback(songs[0])
mQueue.value = songs
mCurrentIndex.value = 0
}
private fun updatePlayback(song: Song) {
mCurrentSong.value = song
mCurrentDuration.value = 0
if (!mIsPlaying.value!!) {
mIsPlaying.value = true
}
}
// Basic sorting functions when things are played in order
private fun orderSongsInAlbum(album: Album): MutableList<Song> {
return album.songs.sortedBy { it.track }.toMutableList()
}
private fun orderSongsInArtist(artist: Artist): MutableList<Song> {
val final = mutableListOf<Song>()
artist.albums.sortedByDescending { it.year }.forEach {
final.addAll(it.songs.sortedBy { it.track })
}
return final
}
private fun orderSongsInGenre(genre: Genre): MutableList<Song> {
val final = mutableListOf<Song>()
genre.artists.sortedByDescending { it.name }.forEach { artist ->
artist.albums.sortedByDescending { it.year }.forEach { album ->
final.addAll(album.songs.sortedBy { it.track })
}
}
return final
}
// Invert, not directly set the playing status
fun invertPlayingStatus() {
mIsPlaying.value = !mIsPlaying.value!!
}
// Set the seeking status
fun setSeekingStatus(status: Boolean) {
mIsSeeking.value = status
}
// Update the current duration using a SeekBar progress // Update the current duration using a SeekBar progress
fun updateCurrentDurationWithProgress(progress: Int) { fun updateCurrentDurationWithProgress(progress: Int) {
mCurrentDuration.value = progress.toLong() mCurrentDuration.value = progress.toLong()
} }
// Invert, not directly set the playing/shuffling status
// Used by the toggle buttons in playback fragment.
fun invertPlayingStatus() {
mIsPlaying.value = !mIsPlaying.value!!
}
fun invertShuffleStatus() {
mIsShuffling.value = !mIsShuffling.value!!
if (mIsShuffling.value!!) {
genShuffle(true)
} else {
resetShuffle()
}
}
// Shuffle all the songs.
fun shuffleAll() {
val musicStore = MusicStore.getInstance()
mIsShuffling.value = true
mQueue.value = musicStore.songs.toMutableList()
mCurrentMode.value = PlaybackMode.ALL_SONGS
mCurrentIndex.value = 0
genShuffle(false)
updatePlayback(mQueue.value!![0])
}
// Set the seeking status
fun setSeekingStatus(status: Boolean) {
mIsSeeking.value = status
}
fun skipNext() { fun skipNext() {
if (mCurrentIndex.value!! < mQueue.value!!.size) { if (mCurrentIndex.value!! < mQueue.value!!.size) {
mCurrentIndex.value = mCurrentIndex.value!!.inc() mCurrentIndex.value = mCurrentIndex.value!!.inc()
@ -172,4 +170,64 @@ class PlaybackViewModel : ViewModel() {
updatePlayback(mQueue.value!![mCurrentIndex.value!!]) updatePlayback(mQueue.value!![mCurrentIndex.value!!])
} }
private fun updatePlayback(song: Song) {
mCurrentSong.value = song
mCurrentDuration.value = 0
if (!mIsPlaying.value!!) {
mIsPlaying.value = true
}
}
// Generate a new shuffled queue.
private fun genShuffle(keepSong: Boolean) {
// Take a random seed and then shuffle the current queue based off of that.
// This seed will be saved in a bundle if the app closes, so that the shuffle mode
// can be restored when its started again.
val newSeed = Random.Default.nextLong()
Log.d(this::class.simpleName, "Shuffling queue with a seed of $newSeed.")
mShuffleSeed.value = newSeed
mQueue.value!!.shuffle(Random(newSeed))
mCurrentIndex.value = 0
// If specified, make the current song the first member of the queue.
if (keepSong) {
mQueue.value!!.remove(mCurrentSong.value)
mQueue.value!!.add(0, mCurrentSong.value!!)
} else {
// Otherwise, just start from the zeroth position in the queue.
mCurrentSong.value = mQueue.value!![0]
}
}
private fun resetShuffle() {
mShuffleSeed.value = -1
mQueue.value = when (mCurrentMode.value!!) {
PlaybackMode.IN_ARTIST -> orderSongsInArtist(mCurrentParent.value as Artist)
PlaybackMode.IN_ALBUM -> orderSongsInAlbum(mCurrentParent.value as Album)
PlaybackMode.ALL_SONGS -> MusicStore.getInstance().songs.toMutableList()
}
mCurrentIndex.value = mQueue.value!!.indexOf(mCurrentSong.value)
}
// Basic sorting functions when things are played in order
private fun orderSongsInAlbum(album: Album): MutableList<Song> {
return album.songs.sortedBy { it.track }.toMutableList()
}
private fun orderSongsInArtist(artist: Artist): MutableList<Song> {
val final = mutableListOf<Song>()
artist.albums.sortedByDescending { it.year }.forEach {
final.addAll(it.songs.sortedBy { it.track })
}
return final
}
} }

View file

@ -7,6 +7,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentSongsBinding import org.oxycblt.auxio.databinding.FragmentSongsBinding
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.PlaybackMode import org.oxycblt.auxio.playback.PlaybackMode
@ -30,6 +31,13 @@ class SongsFragment : Fragment() {
// --- UI SETUP --- // --- UI SETUP ---
binding.songToolbar.setOnMenuItemClickListener {
if (it.itemId == R.id.action_shuffle) {
playbackModel.shuffleAll()
}
true
}
binding.songRecycler.apply { binding.songRecycler.apply {
adapter = SongAdapter(musicStore.songs) { adapter = SongAdapter(musicStore.songs) {
playbackModel.update(it, PlaybackMode.ALL_SONGS) playbackModel.update(it, PlaybackMode.ALL_SONGS)

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="32dp"
android:height="32dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/control_color">
<path
android:fillColor="@android:color/white"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z" />
</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:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/control_color">
<path
android:fillColor="@android:color/white"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z" />
</vector>

View file

@ -185,5 +185,19 @@
app:layout_constraintEnd_toStartOf="@+id/playback_play_pause" app:layout_constraintEnd_toStartOf="@+id/playback_play_pause"
app:layout_constraintTop_toTopOf="@+id/playback_play_pause" /> app:layout_constraintTop_toTopOf="@+id/playback_play_pause" />
<ImageButton
android:id="@+id/playback_shuffle"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="@dimen/size_play_pause_compact"
android:layout_height="@dimen/size_play_pause_compact"
android:layout_marginEnd="@dimen/margin_mid_large"
android:src="@drawable/ic_shuffle"
android:background="@drawable/ui_unbounded_ripple"
android:onClick="@{() -> playbackModel.invertShuffleStatus()}"
android:contentDescription="@{playbackModel.isShuffling() ? @string/description_shuffle_off : @string/description_shuffle_on"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/playback_play_pause" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</layout> </layout>

View file

@ -16,6 +16,7 @@
android:layout_height="?android:attr/actionBarSize" android:layout_height="?android:attr/actionBarSize"
android:background="?android:attr/windowBackground" android:background="?android:attr/windowBackground"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
app:menu="@menu/menu_songs"
app:titleTextAppearance="@style/TextAppearance.Toolbar.Header" app:titleTextAppearance="@style/TextAppearance.Toolbar.Header"
app:title="@string/title_all_songs" /> app:title="@string/title_all_songs" />

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_shuffle"
android:icon="@drawable/ic_shuffle_small"
android:title="@string/label_shuffle"
app:showAsAction="always" />
</menu>

View file

@ -23,6 +23,7 @@
<string name="label_sort_none">Default</string> <string name="label_sort_none">Default</string>
<string name="label_sort_alpha_down">A-Z</string> <string name="label_sort_alpha_down">A-Z</string>
<string name="label_sort_alpha_up">Z-A</string> <string name="label_sort_alpha_up">Z-A</string>
<string name="label_shuffle">Shuffle</string>
<!-- Hint Namespace | EditText Hints --> <!-- Hint Namespace | EditText Hints -->
<string name="hint_search_library">Search Library…</string> <string name="hint_search_library">Search Library…</string>
@ -41,6 +42,8 @@
<string name="description_pause">Pause</string> <string name="description_pause">Pause</string>
<string name="description_skip_next">Skip to next song</string> <string name="description_skip_next">Skip to next song</string>
<string name="description_skip_prev">Skip to last song</string> <string name="description_skip_prev">Skip to last song</string>
<string name="description_shuffle_on">Turn shuffle on</string>
<string name="description_shuffle_off">Turn shuffle off</string>
<!-- Placeholder Namespace | Placeholder values --> <!-- Placeholder Namespace | Placeholder values -->
<string name="placeholder_genre">Unknown Genre</string> <string name="placeholder_genre">Unknown Genre</string>

View file

@ -1,15 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- TODO: Try to make some of these styles better -->
<!-- Base theme --> <!-- Base theme -->
<style name="Theme.Base" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="Theme.Base" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/background</item> <item name="android:windowBackground">@color/background</item>
<item name="android:statusBarColor">@android:color/black</item> <item name="android:statusBarColor">@android:color/black</item>
<item name="android:fontFamily">@font/inter</item> <item name="android:fontFamily">@font/inter</item>
<item name="android:textCursorDrawable">@drawable/ui_cursor</item> <item name="android:textCursorDrawable">@drawable/ui_cursor</item>
<item name="actionBarPopupTheme">@style/AppThemeOverlay.Popup</item>
</style> </style>
<!-- Toolbar Themes -->
<style name="Toolbar.Style" parent="ThemeOverlay.MaterialComponents.ActionBar"> <style name="Toolbar.Style" parent="ThemeOverlay.MaterialComponents.ActionBar">
<item name="android:searchViewStyle">@style/Widget.AppCompat.SearchView</item> <item name="android:searchViewStyle">@style/Widget.AppCompat.SearchView</item>
</style> </style>
@ -19,6 +18,7 @@
<item name="android:textColor">?android:attr/colorPrimary</item> <item name="android:textColor">?android:attr/colorPrimary</item>
</style> </style>
<!-- Header Themes -->
<style name="DetailHeader"> <style name="DetailHeader">
<item name="android:textAppearance">?android:attr/textAppearanceLarge</item> <item name="android:textAppearance">?android:attr/textAppearanceLarge</item>
<item name="android:textColor">?android:attr/colorPrimary</item> <item name="android:textColor">?android:attr/colorPrimary</item>
@ -30,12 +30,9 @@
<item name="android:fontFamily">@font/inter_semibold</item> <item name="android:fontFamily">@font/inter_semibold</item>
</style> </style>
<!-- Custom popup theme -->
<style name="AppThemeOverlay.Popup" parent="ThemeOverlay.AppCompat.DayNight"> <style name="AppThemeOverlay.Popup" parent="ThemeOverlay.AppCompat.DayNight">
<item name="android:colorBackground">@color/background</item> <item name="android:colorBackground">@color/background</item>
<item name="colorControlHighlight">@color/selection_color</item> <item name="colorControlHighlight">@color/selection_color</item>
</style> </style>
<style name="Theme.BottomSheet" parent="Theme.Design.BottomSheetDialog">
<item name="android:colorPrimary">?android:attr/colorPrimary</item>
</style>
</resources> </resources>