Add shuffling
Add basic shuffling to PlaybackFragment.
This commit is contained in:
parent
339100e436
commit
c422071e93
15 changed files with 190 additions and 78 deletions
|
@ -8,6 +8,7 @@ import org.oxycblt.auxio.music.Album
|
|||
import org.oxycblt.auxio.recycler.DiffCallback
|
||||
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
|
||||
|
||||
// TODO: Add ability to highlight currently playing songs
|
||||
class DetailAlbumAdapter(
|
||||
private val doOnClick: (Album) -> Unit
|
||||
) : ListAdapter<Album, DetailAlbumAdapter.ViewHolder>(DiffCallback()) {
|
||||
|
|
|
@ -11,7 +11,6 @@ import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
|
|||
class DetailSongAdapter(
|
||||
private val doOnClick: (Song) -> Unit
|
||||
) : ListAdapter<Song, DetailSongAdapter.ViewHolder>(DiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(
|
||||
ItemAlbumSongBinding.inflate(LayoutInflater.from(parent.context))
|
||||
|
|
|
@ -22,7 +22,9 @@ class MusicStore private constructor() {
|
|||
private var mSongs = listOf<Song>()
|
||||
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...")
|
||||
|
||||
val start = System.currentTimeMillis()
|
||||
|
|
|
@ -25,7 +25,6 @@ class CompactPlaybackFragment : Fragment() {
|
|||
): View? {
|
||||
val binding = FragmentCompactPlaybackBinding.inflate(inflater)
|
||||
|
||||
// FIXME: Stop these icons from self-animating on creation.
|
||||
val iconPauseToPlay = ContextCompat.getDrawable(
|
||||
requireContext(), R.drawable.ic_pause_to_play
|
||||
) as AnimatedVectorDrawable
|
||||
|
|
|
@ -23,9 +23,7 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
|
|||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
|
||||
// TODO: Implement media controls
|
||||
// TODO: Make exit icon bigger
|
||||
// TODO: Implement nav to artists/albums
|
||||
// TODO: Possibly implement a trackbar with a spectrum shown as well.
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
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) {
|
||||
// Highlight the current duration if the user is seeking, and revert it if not.
|
||||
if (it) {
|
||||
|
|
|
@ -7,12 +7,4 @@ package org.oxycblt.auxio.playback
|
|||
// IN_ALBUM -> Play from the songs of the album
|
||||
enum class PlaybackMode {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,10 +8,11 @@ import androidx.lifecycle.ViewModel
|
|||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.BaseModel
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.toDuration
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.Random.Default.nextLong
|
||||
|
||||
// TODO: Implement media controls
|
||||
// TODO: Implement persistence
|
||||
|
@ -39,6 +40,11 @@ class PlaybackViewModel : ViewModel() {
|
|||
private val mIsPlaying = MutableLiveData(false)
|
||||
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)
|
||||
val isSeeking: LiveData<Boolean> get() = mIsSeeking
|
||||
|
||||
|
@ -57,6 +63,8 @@ class PlaybackViewModel : ViewModel() {
|
|||
|
||||
val musicStore = MusicStore.getInstance()
|
||||
|
||||
mCurrentMode.value = mode
|
||||
|
||||
updatePlayback(song)
|
||||
|
||||
mQueue.value = when (mode) {
|
||||
|
@ -65,7 +73,18 @@ class PlaybackViewModel : ViewModel() {
|
|||
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)
|
||||
}
|
||||
|
||||
|
@ -79,6 +98,7 @@ class PlaybackViewModel : ViewModel() {
|
|||
mQueue.value = songs
|
||||
mCurrentIndex.value = 0
|
||||
mCurrentParent.value = album
|
||||
mIsShuffling.value = isShuffled
|
||||
mCurrentMode.value = PlaybackMode.IN_ALBUM
|
||||
}
|
||||
|
||||
|
@ -92,71 +112,49 @@ class PlaybackViewModel : ViewModel() {
|
|||
mQueue.value = songs
|
||||
mCurrentIndex.value = 0
|
||||
mCurrentParent.value = artist
|
||||
mIsShuffling.value = isShuffled
|
||||
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
|
||||
fun updateCurrentDurationWithProgress(progress: Int) {
|
||||
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() {
|
||||
if (mCurrentIndex.value!! < mQueue.value!!.size) {
|
||||
mCurrentIndex.value = mCurrentIndex.value!!.inc()
|
||||
|
@ -172,4 +170,64 @@ class PlaybackViewModel : ViewModel() {
|
|||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentSongsBinding
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.playback.PlaybackMode
|
||||
|
@ -30,6 +31,13 @@ class SongsFragment : Fragment() {
|
|||
|
||||
// --- UI SETUP ---
|
||||
|
||||
binding.songToolbar.setOnMenuItemClickListener {
|
||||
if (it.itemId == R.id.action_shuffle) {
|
||||
playbackModel.shuffleAll()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
binding.songRecycler.apply {
|
||||
adapter = SongAdapter(musicStore.songs) {
|
||||
playbackModel.update(it, PlaybackMode.ALL_SONGS)
|
||||
|
|
11
app/src/main/res/drawable/ic_shuffle.xml
Normal file
11
app/src/main/res/drawable/ic_shuffle.xml
Normal 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>
|
11
app/src/main/res/drawable/ic_shuffle_small.xml
Normal file
11
app/src/main/res/drawable/ic_shuffle_small.xml
Normal 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>
|
|
@ -185,5 +185,19 @@
|
|||
app:layout_constraintEnd_toStartOf="@+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>
|
||||
</layout>
|
|
@ -16,6 +16,7 @@
|
|||
android:layout_height="?android:attr/actionBarSize"
|
||||
android:background="?android:attr/windowBackground"
|
||||
android:elevation="@dimen/elevation_normal"
|
||||
app:menu="@menu/menu_songs"
|
||||
app:titleTextAppearance="@style/TextAppearance.Toolbar.Header"
|
||||
app:title="@string/title_all_songs" />
|
||||
|
||||
|
|
9
app/src/main/res/menu/menu_songs.xml
Normal file
9
app/src/main/res/menu/menu_songs.xml
Normal 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>
|
|
@ -23,6 +23,7 @@
|
|||
<string name="label_sort_none">Default</string>
|
||||
<string name="label_sort_alpha_down">A-Z</string>
|
||||
<string name="label_sort_alpha_up">Z-A</string>
|
||||
<string name="label_shuffle">Shuffle</string>
|
||||
|
||||
<!-- Hint Namespace | EditText Hints -->
|
||||
<string name="hint_search_library">Search Library…</string>
|
||||
|
@ -41,6 +42,8 @@
|
|||
<string name="description_pause">Pause</string>
|
||||
<string name="description_skip_next">Skip to next 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 -->
|
||||
<string name="placeholder_genre">Unknown Genre</string>
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- TODO: Try to make some of these styles better -->
|
||||
<!-- Base theme -->
|
||||
<style name="Theme.Base" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/background</item>
|
||||
<item name="android:statusBarColor">@android:color/black</item>
|
||||
<item name="android:fontFamily">@font/inter</item>
|
||||
<item name="android:textCursorDrawable">@drawable/ui_cursor</item>
|
||||
<item name="actionBarPopupTheme">@style/AppThemeOverlay.Popup</item>
|
||||
</style>
|
||||
|
||||
<!-- Toolbar Themes -->
|
||||
<style name="Toolbar.Style" parent="ThemeOverlay.MaterialComponents.ActionBar">
|
||||
<item name="android:searchViewStyle">@style/Widget.AppCompat.SearchView</item>
|
||||
</style>
|
||||
|
@ -19,6 +18,7 @@
|
|||
<item name="android:textColor">?android:attr/colorPrimary</item>
|
||||
</style>
|
||||
|
||||
<!-- Header Themes -->
|
||||
<style name="DetailHeader">
|
||||
<item name="android:textAppearance">?android:attr/textAppearanceLarge</item>
|
||||
<item name="android:textColor">?android:attr/colorPrimary</item>
|
||||
|
@ -30,12 +30,9 @@
|
|||
<item name="android:fontFamily">@font/inter_semibold</item>
|
||||
</style>
|
||||
|
||||
<!-- Custom popup theme -->
|
||||
<style name="AppThemeOverlay.Popup" parent="ThemeOverlay.AppCompat.DayNight">
|
||||
<item name="android:colorBackground">@color/background</item>
|
||||
<item name="colorControlHighlight">@color/selection_color</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.BottomSheet" parent="Theme.Design.BottomSheetDialog">
|
||||
<item name="android:colorPrimary">?android:attr/colorPrimary</item>
|
||||
</style>
|
||||
</resources>
|
Loading…
Reference in a new issue