Refactor playback management

Move playback management from PlaybackViewModel to a dedicated class, update naming of playback functions heavily.
This commit is contained in:
OxygenCobalt 2020-10-26 09:39:45 -06:00
parent 25142bba48
commit 42325c456e
14 changed files with 523 additions and 348 deletions

View file

@ -92,7 +92,7 @@ class MainFragment : Fragment() {
// --- VIEWMODEL SETUP ---
// Change CompactPlaybackFragment's visibility here so that an animation occurs.
playbackModel.currentSong.observe(viewLifecycleOwner) {
playbackModel.song.observe(viewLifecycleOwner) {
if (it == null) {
Log.d(
this::class.simpleName,

View file

@ -13,7 +13,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentAlbumDetailBinding
import org.oxycblt.auxio.detail.adapters.DetailSongAdapter
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.PlaybackMode
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.theme.applyDivider
import org.oxycblt.auxio.theme.disable
@ -47,7 +47,7 @@ class AlbumDetailFragment : Fragment() {
}
val songAdapter = DetailSongAdapter {
playbackModel.update(it, PlaybackMode.IN_ALBUM)
playbackModel.playSong(it, PlaybackMode.IN_ALBUM)
}
// --- UI SETUP ---
@ -64,11 +64,13 @@ class AlbumDetailFragment : Fragment() {
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_shuffle -> playbackModel.play(
R.id.action_shuffle -> playbackModel.playAlbum(
detailModel.currentAlbum.value!!,
true
)
R.id.action_play -> playbackModel.play(detailModel.currentAlbum.value!!, false)
R.id.action_play -> playbackModel.playAlbum(
detailModel.currentAlbum.value!!, false
)
}
true

View file

@ -67,11 +67,13 @@ class ArtistDetailFragment : Fragment() {
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_shuffle -> playbackModel.play(
R.id.action_shuffle -> playbackModel.playArtist(
detailModel.currentArtist.value!!,
true
)
R.id.action_play -> playbackModel.play(detailModel.currentArtist.value!!, false)
R.id.action_play -> playbackModel.playArtist(
detailModel.currentArtist.value!!, false
)
}
true

View file

@ -67,11 +67,13 @@ class GenreDetailFragment : Fragment() {
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_shuffle -> playbackModel.play(
R.id.action_shuffle -> playbackModel.playGenre(
detailModel.currentGenre.value!!,
true
)
R.id.action_play -> playbackModel.play(detailModel.currentGenre.value!!, false)
R.id.action_play -> playbackModel.playGenre(
detailModel.currentGenre.value!!, false
)
}
true

View file

@ -25,7 +25,7 @@ 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.playback.PlaybackMode
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.theme.applyColor
import org.oxycblt.auxio.theme.applyDivider
@ -119,8 +119,6 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
}
}
// FIXME: Change LibraryAdapter to a ListAdapter
// [If there's a way to preserve scroll position properly]
binding.libraryRecycler.apply {
adapter = libraryAdapter
@ -183,7 +181,7 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
// If the item is a song [That was selected through search], then update the playback
// to that song instead of doing any navigation
if (baseModel is Song) {
playbackModel.update(baseModel, PlaybackMode.ALL_SONGS)
playbackModel.playSong(baseModel, PlaybackMode.ALL_SONGS)
return
}

View file

@ -16,7 +16,9 @@ import org.oxycblt.auxio.databinding.FragmentCompactPlaybackBinding
import org.oxycblt.auxio.music.MusicStore
class CompactPlaybackFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels {
PlaybackViewModel.Factory(requireContext())
}
override fun onCreateView(
inflater: LayoutInflater,
@ -50,7 +52,7 @@ class CompactPlaybackFragment : Fragment() {
// --- VIEWMODEL SETUP ---
playbackModel.currentSong.observe(viewLifecycleOwner) {
playbackModel.song.observe(viewLifecycleOwner) {
if (it != null) {
Log.d(this::class.simpleName, "Updating song display to ${it.name}")
@ -78,7 +80,7 @@ class CompactPlaybackFragment : Fragment() {
}
}
playbackModel.formattedSeekBarProgress.observe(viewLifecycleOwner) {
playbackModel.positionAsProgress.observe(viewLifecycleOwner) {
binding.playbackProgress.progress = it
}

View file

@ -51,7 +51,7 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
binding.lifecycleOwner = viewLifecycleOwner
binding.playbackModel = playbackModel
binding.song = playbackModel.currentSong.value!!
binding.song = playbackModel.song.value!!
binding.playbackToolbar.apply {
setNavigationOnClickListener {
@ -73,14 +73,14 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
// --- VIEWMODEL SETUP --
playbackModel.currentSong.observe(viewLifecycleOwner) {
playbackModel.song.observe(viewLifecycleOwner) {
Log.d(this::class.simpleName, "Updating song display to ${it.name}.")
binding.song = it
binding.playbackSeekBar.max = it.seconds.toInt()
}
playbackModel.currentIndex.observe(viewLifecycleOwner) {
playbackModel.index.observe(viewLifecycleOwner) {
if (it > 0) {
binding.playbackSkipPrev.enable(requireContext())
} else {
@ -129,11 +129,11 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
}
// Updates for the current duration TextView/Seekbar
playbackModel.formattedCurrentDuration.observe(viewLifecycleOwner) {
playbackModel.formattedPosition.observe(viewLifecycleOwner) {
binding.playbackDurationCurrent.text = it
}
playbackModel.formattedSeekBarProgress.observe(viewLifecycleOwner) {
playbackModel.positionAsProgress.observe(viewLifecycleOwner) {
binding.playbackSeekBar.progress = it
}
@ -145,7 +145,7 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
// Seeking callbacks
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
playbackModel.updateCurrentDurationWithProgress(progress)
playbackModel.updatePositionWithProgress(progress)
}
}

View file

@ -10,8 +10,10 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toURI
import org.oxycblt.auxio.playback.state.PlaybackStateCallback
import org.oxycblt.auxio.playback.state.PlaybackStateManager
class PlaybackService : Service(), Player.EventListener {
class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
private val player: SimpleExoPlayer by lazy {
val p = SimpleExoPlayer.Builder(applicationContext).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -22,23 +24,32 @@ class PlaybackService : Service(), Player.EventListener {
}
private val mBinder = LocalBinder()
private val playbackManager = PlaybackStateManager.getInstance()
override fun onBind(intent: Intent): IBinder {
return mBinder
}
override fun onCreate() {
super.onCreate()
playbackManager.addCallback(this)
}
override fun onDestroy() {
super.onDestroy()
player.release()
playbackManager.removeCallback(this)
}
fun playSong(song: Song) {
val item = MediaItem.fromUri(song.id.toURI())
player.setMediaItem(item)
player.prepare()
player.play()
override fun onSongUpdate(song: Song?) {
song?.let {
val item = MediaItem.fromUri(it.id.toURI())
player.setMediaItem(item)
player.prepare()
player.play()
}
}
inner class LocalBinder : Binder() {

View file

@ -11,55 +11,60 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.exoplayer2.Player
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
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.playback.state.PlaybackStateCallback
import org.oxycblt.auxio.playback.state.PlaybackStateManager
// TODO: User managed queue
// TODO: Add the playback service itself
// TODO: Add loop control [From playback]
// TODO: Implement persistence through Bundles [I want to keep my shuffles, okay?]
// A ViewModel that acts as an intermediary between PlaybackService and the Playback Fragments.
class PlaybackViewModel(private val context: Context) : ViewModel(), Player.EventListener {
private val mCurrentSong = MutableLiveData<Song>()
val currentSong: LiveData<Song> get() = mCurrentSong
// The UI frontend for PlaybackStateManager.
class PlaybackViewModel(private val context: Context) : ViewModel(), PlaybackStateCallback {
// Playback
private val mSong = MutableLiveData<Song>()
val song: LiveData<Song> get() = mSong
private val mCurrentParent = MutableLiveData<BaseModel>()
val currentParent: LiveData<BaseModel> get() = mCurrentParent
private val mPosition = MutableLiveData(0L)
val position: LiveData<Long> get() = mPosition
// Queue
private val mQueue = MutableLiveData(mutableListOf<Song>())
val queue: LiveData<MutableList<Song>> get() = mQueue
private val mCurrentIndex = MutableLiveData(0)
val currentIndex: LiveData<Int> get() = mCurrentIndex
private val mCurrentMode = MutableLiveData(PlaybackMode.ALL_SONGS)
val currentMode: LiveData<PlaybackMode> get() = mCurrentMode
private val mCurrentDuration = MutableLiveData(0L)
private val mIndex = MutableLiveData(0)
val index: LiveData<Int> get() = mIndex
// States
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)
val shuffleSeed: LiveData<Long> get() = mShuffleSeed
// Other
private val mIsSeeking = MutableLiveData(false)
val isSeeking: LiveData<Boolean> get() = mIsSeeking
val formattedPosition = Transformations.map(mPosition) {
it.toDuration()
}
val positionAsProgress = Transformations.map(mPosition) {
if (mSong.value != null) it.toInt() else 0
}
val nextItemsInQueue = Transformations.map(mQueue) {
it.slice((mIndex.value!! + 1) until it.size)
}
private var mCanAnimate = false
val canAnimate: Boolean get() = mCanAnimate
// Service setup
private val playbackManager = PlaybackStateManager.getInstance()
private lateinit var playbackService: PlaybackService
private var playbackIntent: Intent
@ -79,342 +84,137 @@ class PlaybackViewModel(private val context: Context) : ViewModel(), Player.Even
}
}
// Formatted variants of the duration
val formattedCurrentDuration = Transformations.map(mCurrentDuration) {
it.toDuration()
init {
playbackManager.addCallback(this)
}
val formattedSeekBarProgress = Transformations.map(mCurrentDuration) {
if (mCurrentSong.value != null) it.toInt() else 0
// --- PLAYING FUNCTIONS ---
fun playSong(song: Song, mode: PlaybackMode) {
playbackManager.playSong(song, mode)
}
// Formatted queue that shows all the songs after the current playing song.
val formattedQueue = Transformations.map(mQueue) {
it.slice((mCurrentIndex.value!! + 1) until it.size)
fun shuffleAll() {
playbackManager.shuffleAll()
}
// Update the current song while changing the queue mode.
fun update(song: Song, mode: PlaybackMode) {
// Auxio doesn't support playing songs while swapping the mode to GENRE, as its impossible
// to determine what genre a song has.
if (mode == PlaybackMode.IN_GENRE) {
Log.e(this::class.simpleName, "Auxio cant play songs with the mode of IN_GENRE.")
return
}
Log.d(this::class.simpleName, "Updating song to ${song.name} and mode to $mode")
val musicStore = MusicStore.getInstance()
mCurrentMode.value = mode
updatePlayback(song)
mQueue.value = when (mode) {
PlaybackMode.ALL_SONGS -> musicStore.songs.toMutableList()
PlaybackMode.IN_ARTIST -> song.album.artist.songs
PlaybackMode.IN_ALBUM -> song.album.songs
PlaybackMode.IN_GENRE -> error("what")
}
mCurrentParent.value = when (mode) {
PlaybackMode.ALL_SONGS -> null
PlaybackMode.IN_ARTIST -> song.album.artist
PlaybackMode.IN_ALBUM -> song.album
PlaybackMode.IN_GENRE -> error("what")
}
if (mIsShuffling.value!!) {
genShuffle(true)
} else {
resetShuffle()
}
mCurrentIndex.value = mQueue.value!!.indexOf(song)
}
// Play some parent music model, whether that being albums/artists/genres.
fun play(album: Album, isShuffled: Boolean) {
Log.d(this::class.simpleName, "Playing album ${album.name}")
fun playAlbum(album: Album, shuffled: Boolean) {
if (album.songs.isEmpty()) {
Log.e(this::class.simpleName, "Album is empty, not playing.")
Log.e(this::class.simpleName, "Album is empty, Not playing.")
return
}
val songs = orderSongsInAlbum(album)
updatePlayback(songs[0])
mQueue.value = songs
mCurrentIndex.value = 0
mCurrentParent.value = album
mIsShuffling.value = isShuffled
mCurrentMode.value = PlaybackMode.IN_ALBUM
if (mIsShuffling.value!!) {
genShuffle(false)
} else {
resetShuffle()
}
playbackManager.playParentModel(album, shuffled)
}
fun play(artist: Artist, isShuffled: Boolean) {
Log.d(this::class.simpleName, "Playing artist ${artist.name}")
fun playArtist(artist: Artist, shuffled: Boolean) {
if (artist.songs.isEmpty()) {
Log.e(this::class.simpleName, "Artist is empty, not playing.")
Log.e(this::class.simpleName, "Artist is empty, Not playing.")
return
}
val songs = orderSongsInArtist(artist)
updatePlayback(songs[0])
mQueue.value = songs
mCurrentIndex.value = 0
mCurrentParent.value = artist
mIsShuffling.value = isShuffled
mCurrentMode.value = PlaybackMode.IN_ARTIST
if (mIsShuffling.value!!) {
genShuffle(false)
} else {
resetShuffle()
}
playbackManager.playParentModel(artist, shuffled)
}
fun play(genre: Genre, isShuffled: Boolean) {
Log.d(this::class.simpleName, "Playing genre ${genre.name}")
fun playGenre(genre: Genre, shuffled: Boolean) {
if (genre.songs.isEmpty()) {
Log.e(this::class.simpleName, "Genre is empty, not playing.")
Log.e(this::class.simpleName, "Genre is empty, Not playing.")
return
}
val songs = orderSongsInGenre(genre)
updatePlayback(songs[0])
mQueue.value = songs
mCurrentIndex.value = 0
mCurrentParent.value = genre
mIsShuffling.value = isShuffled
mCurrentMode.value = PlaybackMode.IN_GENRE
if (mIsShuffling.value!!) {
genShuffle(false)
} else {
resetShuffle()
}
playbackManager.playParentModel(genre, shuffled)
}
// Update the current duration using a SeekBar progress
fun updateCurrentDurationWithProgress(progress: Int) {
mCurrentDuration.value = progress.toLong()
// --- POSITION FUNCTIONS ---
fun updatePositionWithProgress(progress: Int) {
playbackManager.setPosition(progress.toLong())
}
// Invert, not directly set the playing/shuffling status
// Used by the toggle buttons in playback fragment.
// --- QUEUE FUNCTIONS ---
fun skipNext() {
playbackManager.skipNext()
}
fun skipPrev() {
playbackManager.skipPrev()
}
fun removeQueueItem(adapterIndex: Int) {
// Translate the adapter indices into the correct queue indices
val delta = mQueue.value!!.size - nextItemsInQueue.value!!.size
val index = adapterIndex + delta
playbackManager.removeQueueItem(index)
}
fun moveQueueItems(adapterFrom: Int, adapterTo: Int) {
// Translate the adapter indices into the correct queue indices
val delta = mQueue.value!!.size - nextItemsInQueue.value!!.size
val from = adapterFrom + delta
val to = adapterTo + delta
playbackManager.moveQueueItems(from, to)
}
// --- STATUS FUNCTIONS ---
fun invertPlayingStatus() {
mCanAnimate = true
mIsPlaying.value = !mIsPlaying.value!!
playbackManager.setPlayingStatus(!playbackManager.isPlaying)
}
fun invertShuffleStatus() {
mIsShuffling.value = !mIsShuffling.value!!
if (mIsShuffling.value!!) {
genShuffle(true)
} else {
resetShuffle()
}
playbackManager.setShuffleStatus(!playbackManager.isShuffling)
}
// 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
}
// Skip to next song
fun skipNext() {
if (mCurrentIndex.value!! < mQueue.value!!.size) {
mCurrentIndex.value = mCurrentIndex.value!!.inc()
}
updatePlayback(mQueue.value!![mCurrentIndex.value!!])
forceQueueUpdate()
}
// Skip to last song
fun skipPrev() {
if (mCurrentIndex.value!! > 0) {
mCurrentIndex.value = mCurrentIndex.value!!.dec()
}
updatePlayback(mQueue.value!![mCurrentIndex.value!!])
forceQueueUpdate()
}
// --- OTHER FUNCTIONS ---
fun resetAnimStatus() {
mCanAnimate = false
}
// Move two queue items. Note that this function does not force-update the queue,
// as calling updateData with a drag would cause bugs.
fun moveQueueItems(adapterFrom: Int, adapterTo: Int) {
// Translate the adapter indices into the correct queue indices
val delta = mQueue.value!!.size - formattedQueue.value!!.size
val from = adapterFrom + delta
val to = adapterTo + delta
try {
val currentItem = mQueue.value!![from]
mQueue.value!!.removeAt(from)
mQueue.value!!.add(to, currentItem)
} catch (exception: IndexOutOfBoundsException) {
Log.e(this::class.simpleName, "Indices were out of bounds, did not move queue item")
return
}
forceQueueUpdate()
fun setSeekingStatus(value: Boolean) {
mIsSeeking.value = value
}
// Remove a queue item. Note that this function does not force-update the queue,
// as calling updateData with a drag would cause bugs.
fun removeQueueItem(adapterIndex: Int) {
// Translate the adapter index into the correct queue index
val delta = mQueue.value!!.size - formattedQueue.value!!.size
val properIndex = adapterIndex + delta
Log.d(this::class.simpleName, "Removing item ${mQueue.value!![properIndex].name}.")
if (properIndex > mQueue.value!!.size || properIndex < 0) {
Log.e(this::class.simpleName, "Index is out of bounds, did not remove queue item.")
return
}
mQueue.value!!.removeAt(properIndex)
forceQueueUpdate()
}
// Force the observers of the queue to actually update after making changes.
private fun forceQueueUpdate() {
mQueue.value = mQueue.value
}
// Generic function for updating the playback with a new song.
// Use this instead of manually updating the values each time.
private fun updatePlayback(song: Song) {
mCurrentSong.value = song
mCurrentDuration.value = 0
if (!mIsPlaying.value!!) {
mIsPlaying.value = true
}
playbackService.playSong(song)
}
// 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]
}
// Force the observers to actually update.
mQueue.value = mQueue.value
}
// Stop the queue and attempt to restore to the previous state
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.IN_GENRE -> orderSongsInGenre(mCurrentParent.value as Genre)
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 { album ->
final.addAll(album.songs.sortedBy { it.track })
}
return final
}
private fun orderSongsInGenre(genre: Genre): MutableList<Song> {
val final = mutableListOf<Song>()
genre.artists.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }
).forEach { artist ->
artist.albums.sortedByDescending { it.year }.forEach { album ->
final.addAll(album.songs.sortedBy { it.track })
}
}
return final
}
// --- OVERRIDES ---
override fun onCleared() {
super.onCleared()
playbackManager.removeCallback(this)
}
context.unbindService(connection)
override fun onSongUpdate(song: Song?) {
song?.let {
mSong.value = it
}
}
override fun onPositionUpdate(position: Long) {
mPosition.value = position
}
override fun onQueueUpdate(queue: MutableList<Song>) {
mQueue.value = queue
}
override fun onIndexUpdate(index: Int) {
mIndex.value = index
}
override fun onPlayingUpdate(isPlaying: Boolean) {
mIsPlaying.value = isPlaying
}
override fun onShuffleUpdate(isShuffling: Boolean) {
mIsShuffling.value = isShuffling
}
class Factory(private val context: Context) : ViewModelProvider.Factory {

View file

@ -16,7 +16,9 @@ import org.oxycblt.auxio.theme.applyDivider
import org.oxycblt.auxio.theme.toColor
class QueueFragment : BottomSheetDialogFragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels {
PlaybackViewModel.Factory(requireActivity().application)
}
override fun getTheme(): Int = R.style.Theme_BottomSheetFix
@ -46,7 +48,7 @@ class QueueFragment : BottomSheetDialogFragment() {
// --- VIEWMODEL SETUP ---
playbackModel.formattedQueue.observe(viewLifecycleOwner) {
playbackModel.nextItemsInQueue.observe(viewLifecycleOwner) {
// If the first item is being moved, then scroll to the top position on completion
// to prevent ListAdapter from scrolling uncontrollably.
if (queueAdapter.currentList.isNotEmpty() && it[0].id != queueAdapter.currentList[0].id) {

View file

@ -1,4 +1,4 @@
package org.oxycblt.auxio.playback
package org.oxycblt.auxio.playback.state
// Enum for instruction how the queue should function.
// ALL SONGS -> Play from all songs

View file

@ -0,0 +1,12 @@
package org.oxycblt.auxio.playback.state
import org.oxycblt.auxio.music.Song
interface PlaybackStateCallback {
fun onSongUpdate(song: Song?) {}
fun onPositionUpdate(position: Long) {}
fun onQueueUpdate(queue: MutableList<Song>) {}
fun onPlayingUpdate(isPlaying: Boolean) {}
fun onShuffleUpdate(isShuffling: Boolean) {}
fun onIndexUpdate(index: Int) {}
}

View file

@ -0,0 +1,344 @@
package org.oxycblt.auxio.playback.state
import android.util.Log
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.Header
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import kotlin.random.Random
// The manager of the current playback state [Current Song, Queue, Shuffling]
// Never use this for ANYTHING UI related, that's what PlaybackViewModel is for.
class PlaybackStateManager {
// Playback
private var mSong: Song? = null
set(value) {
field = value
callbacks.forEach { it.onSongUpdate(value) }
}
private var mPosition: Long = 0
set(value) {
field = value
callbacks.forEach { it.onPositionUpdate(value) }
}
private var mParent: BaseModel? = null
// Queue
private var mQueue = mutableListOf<Song>()
set(value) {
field = value
callbacks.forEach { it.onQueueUpdate(value) }
}
private var mIndex = 0
set(value) {
field = value
callbacks.forEach { it.onIndexUpdate(value) }
}
private var mMode = PlaybackMode.ALL_SONGS
private var mIsPlaying = false
set(value) {
field = value
callbacks.forEach { it.onPlayingUpdate(value) }
}
private var mIsShuffling = false
set(value) {
field = value
callbacks.forEach { it.onShuffleUpdate(value) }
}
private var mShuffleSeed = -1L
val isPlaying: Boolean get() = mIsPlaying
val isShuffling: Boolean get() = mIsShuffling
// --- CALLBACKS ---
private val callbacks = mutableListOf<PlaybackStateCallback>()
fun addCallback(callback: PlaybackStateCallback) {
callbacks.add(callback)
}
fun removeCallback(callback: PlaybackStateCallback) {
callbacks.remove(callback)
}
// --- PLAYING FUNCTIONS ---
fun playSong(song: Song, mode: PlaybackMode) {
// Auxio doesn't support playing songs while swapping the mode to GENRE, as its impossible
// to determine what genre a song has.
if (mode == PlaybackMode.IN_GENRE) {
Log.e(this::class.simpleName, "Auxio cant play songs with the mode of IN_GENRE.")
return
}
Log.d(this::class.simpleName, "Updating song to ${song.name} and mode to $mode")
val musicStore = MusicStore.getInstance()
mMode = mode
updatePlayback(song)
mQueue = when (mode) {
PlaybackMode.ALL_SONGS -> musicStore.songs.toMutableList()
PlaybackMode.IN_ARTIST -> song.album.artist.songs
PlaybackMode.IN_ALBUM -> song.album.songs
PlaybackMode.IN_GENRE -> error("what")
}
mParent = when (mode) {
PlaybackMode.ALL_SONGS -> null
PlaybackMode.IN_ARTIST -> song.album.artist
PlaybackMode.IN_ALBUM -> song.album
PlaybackMode.IN_GENRE -> error("what")
}
if (mIsShuffling) {
genShuffle(true)
} else {
resetShuffle()
}
mIndex = mQueue.indexOf(song)
}
fun playParentModel(baseModel: BaseModel, shuffled: Boolean) {
if (baseModel is Song || baseModel is Header) {
Log.e(
this::class.simpleName,
"playParentModel does not support ${baseModel::class.simpleName}."
)
return
}
Log.d(this::class.simpleName, "Playing ${baseModel.name}")
when (baseModel) {
is Album -> {
mQueue = orderSongsInAlbum(baseModel)
mMode = PlaybackMode.IN_ALBUM
}
is Artist -> {
mQueue = orderSongsInArtist(baseModel)
mMode = PlaybackMode.IN_ARTIST
}
is Genre -> {
mQueue = orderSongsInGenre(baseModel)
mMode = PlaybackMode.IN_GENRE
}
else -> error("what")
}
updatePlayback(mQueue[0])
mIndex = 0
mParent = baseModel
mIsShuffling = shuffled
if (mIsShuffling) {
genShuffle(false)
} else {
resetShuffle()
}
}
private fun updatePlayback(song: Song) {
mSong = song
mPosition = 0
if (!mIsPlaying) {
mIsPlaying = true
}
}
fun setPosition(position: Long) {
mPosition = position
}
// --- QUEUE FUNCTIONS ---
fun skipNext() {
if (mIndex < mQueue.size) {
mIndex = mIndex.inc()
}
updatePlayback(mQueue[mIndex])
forceQueueUpdate()
}
fun skipPrev() {
if (mIndex > 0) {
mIndex = mIndex.dec()
}
updatePlayback(mQueue[mIndex])
forceQueueUpdate()
}
fun removeQueueItem(index: Int) {
Log.d(this::class.simpleName, "Removing item ${mQueue[index].name}.")
if (index > mQueue.size || index < 0) {
Log.e(this::class.simpleName, "Index is out of bounds, did not remove queue item.")
return
}
mQueue.removeAt(index)
forceQueueUpdate()
}
fun moveQueueItems(from: Int, to: Int) {
try {
val currentItem = mQueue[from]
mQueue.removeAt(from)
mQueue.add(to, currentItem)
} catch (exception: IndexOutOfBoundsException) {
Log.e(this::class.simpleName, "Indices were out of bounds, did not move queue item")
return
}
forceQueueUpdate()
}
private fun forceQueueUpdate() {
mQueue = mQueue
}
// --- SHUFFLE FUNCTIONS ---
fun shuffleAll() {
val musicStore = MusicStore.getInstance()
mIsShuffling = true
mQueue = musicStore.songs.toMutableList()
mMode = PlaybackMode.ALL_SONGS
mIndex = 0
genShuffle(false)
updatePlayback(mQueue[0])
}
// 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 = newSeed
mQueue.shuffle(Random(newSeed))
mIndex = 0
// If specified, make the current song the first member of the queue.
if (keepSong) {
mSong?.let {
mQueue.remove(it)
mQueue.add(0, it)
}
} else {
// Otherwise, just start from the zeroth position in the queue.
mSong = mQueue[0]
}
forceQueueUpdate()
}
// Stop the queue and attempt to restore to the previous state
private fun resetShuffle() {
mShuffleSeed = -1
mQueue = when (mMode) {
PlaybackMode.IN_ARTIST -> orderSongsInArtist(mParent as Artist)
PlaybackMode.IN_ALBUM -> orderSongsInAlbum(mParent as Album)
PlaybackMode.IN_GENRE -> orderSongsInGenre(mParent as Genre)
PlaybackMode.ALL_SONGS -> MusicStore.getInstance().songs.toMutableList()
}
mIndex = mQueue.indexOf(mSong)
}
// --- STATE FUNCTIONS ---
fun setPlayingStatus(value: Boolean) {
if (mIsPlaying != value) {
mIsPlaying = value
}
}
fun setShuffleStatus(value: Boolean) {
mIsShuffling = value
if (mIsShuffling) {
genShuffle(true)
} else {
resetShuffle()
}
}
// --- ORDERING FUNCTIONS ---
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 { album ->
final.addAll(album.songs.sortedBy { it.track })
}
return final
}
private fun orderSongsInGenre(genre: Genre): MutableList<Song> {
val final = mutableListOf<Song>()
genre.artists.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }
).forEach { artist ->
artist.albums.sortedByDescending { it.year }.forEach { album ->
final.addAll(album.songs.sortedBy { it.track })
}
}
return final
}
companion object {
@Volatile
private var INSTANCE: PlaybackStateManager? = null
fun getInstance(): PlaybackStateManager {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
}
synchronized(this) {
val newInstance = PlaybackStateManager()
INSTANCE = newInstance
return newInstance
}
}
}
}

View file

@ -10,7 +10,7 @@ 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
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.theme.applyDivider
@ -42,7 +42,7 @@ class SongsFragment : Fragment() {
binding.songRecycler.apply {
adapter = SongAdapter(musicStore.songs) {
playbackModel.update(it, PlaybackMode.ALL_SONGS)
playbackModel.playSong(it, PlaybackMode.ALL_SONGS)
}
applyDivider()
setHasFixedSize(true)