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 --- // --- VIEWMODEL SETUP ---
// Change CompactPlaybackFragment's visibility here so that an animation occurs. // Change CompactPlaybackFragment's visibility here so that an animation occurs.
playbackModel.currentSong.observe(viewLifecycleOwner) { playbackModel.song.observe(viewLifecycleOwner) {
if (it == null) { if (it == null) {
Log.d( Log.d(
this::class.simpleName, this::class.simpleName,

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre 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.playback.PlaybackMode import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.theme.applyColor import org.oxycblt.auxio.theme.applyColor
import org.oxycblt.auxio.theme.applyDivider 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 { binding.libraryRecycler.apply {
adapter = libraryAdapter 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 // If the item is a song [That was selected through search], then update the playback
// to that song instead of doing any navigation // to that song instead of doing any navigation
if (baseModel is Song) { if (baseModel is Song) {
playbackModel.update(baseModel, PlaybackMode.ALL_SONGS) playbackModel.playSong(baseModel, PlaybackMode.ALL_SONGS)
return return
} }

View file

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

View file

@ -51,7 +51,7 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.playbackModel = playbackModel binding.playbackModel = playbackModel
binding.song = playbackModel.currentSong.value!! binding.song = playbackModel.song.value!!
binding.playbackToolbar.apply { binding.playbackToolbar.apply {
setNavigationOnClickListener { setNavigationOnClickListener {
@ -73,14 +73,14 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
// --- VIEWMODEL SETUP -- // --- VIEWMODEL SETUP --
playbackModel.currentSong.observe(viewLifecycleOwner) { playbackModel.song.observe(viewLifecycleOwner) {
Log.d(this::class.simpleName, "Updating song display to ${it.name}.") Log.d(this::class.simpleName, "Updating song display to ${it.name}.")
binding.song = it binding.song = it
binding.playbackSeekBar.max = it.seconds.toInt() binding.playbackSeekBar.max = it.seconds.toInt()
} }
playbackModel.currentIndex.observe(viewLifecycleOwner) { playbackModel.index.observe(viewLifecycleOwner) {
if (it > 0) { if (it > 0) {
binding.playbackSkipPrev.enable(requireContext()) binding.playbackSkipPrev.enable(requireContext())
} else { } else {
@ -129,11 +129,11 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
} }
// Updates for the current duration TextView/Seekbar // Updates for the current duration TextView/Seekbar
playbackModel.formattedCurrentDuration.observe(viewLifecycleOwner) { playbackModel.formattedPosition.observe(viewLifecycleOwner) {
binding.playbackDurationCurrent.text = it binding.playbackDurationCurrent.text = it
} }
playbackModel.formattedSeekBarProgress.observe(viewLifecycleOwner) { playbackModel.positionAsProgress.observe(viewLifecycleOwner) {
binding.playbackSeekBar.progress = it binding.playbackSeekBar.progress = it
} }
@ -145,7 +145,7 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
// Seeking callbacks // Seeking callbacks
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) { 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 com.google.android.exoplayer2.SimpleExoPlayer
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toURI 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 { private val player: SimpleExoPlayer by lazy {
val p = SimpleExoPlayer.Builder(applicationContext).build() val p = SimpleExoPlayer.Builder(applicationContext).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -22,24 +24,33 @@ class PlaybackService : Service(), Player.EventListener {
} }
private val mBinder = LocalBinder() private val mBinder = LocalBinder()
private val playbackManager = PlaybackStateManager.getInstance()
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
return mBinder return mBinder
} }
override fun onCreate() {
super.onCreate()
playbackManager.addCallback(this)
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
player.release() player.release()
playbackManager.removeCallback(this)
} }
fun playSong(song: Song) { override fun onSongUpdate(song: Song?) {
val item = MediaItem.fromUri(song.id.toURI()) song?.let {
val item = MediaItem.fromUri(it.id.toURI())
player.setMediaItem(item) player.setMediaItem(item)
player.prepare() player.prepare()
player.play() player.play()
} }
}
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
fun getService() = this@PlaybackService fun getService() = this@PlaybackService

View file

@ -11,55 +11,60 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.google.android.exoplayer2.Player
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.Genre import org.oxycblt.auxio.music.Genre
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 org.oxycblt.auxio.playback.state.PlaybackMode
import kotlin.random.Random.Default.nextLong import org.oxycblt.auxio.playback.state.PlaybackStateCallback
import org.oxycblt.auxio.playback.state.PlaybackStateManager
// TODO: User managed queue // The UI frontend for PlaybackStateManager.
// TODO: Add the playback service itself class PlaybackViewModel(private val context: Context) : ViewModel(), PlaybackStateCallback {
// TODO: Add loop control [From playback] // Playback
// TODO: Implement persistence through Bundles [I want to keep my shuffles, okay?] private val mSong = MutableLiveData<Song>()
// A ViewModel that acts as an intermediary between PlaybackService and the Playback Fragments. val song: LiveData<Song> get() = mSong
class PlaybackViewModel(private val context: Context) : ViewModel(), Player.EventListener {
private val mCurrentSong = MutableLiveData<Song>()
val currentSong: LiveData<Song> get() = mCurrentSong
private val mCurrentParent = MutableLiveData<BaseModel>() private val mPosition = MutableLiveData(0L)
val currentParent: LiveData<BaseModel> get() = mCurrentParent val position: LiveData<Long> get() = mPosition
// Queue
private val mQueue = MutableLiveData(mutableListOf<Song>()) private val mQueue = MutableLiveData(mutableListOf<Song>())
val queue: LiveData<MutableList<Song>> get() = mQueue val queue: LiveData<MutableList<Song>> get() = mQueue
private val mCurrentIndex = MutableLiveData(0) private val mIndex = MutableLiveData(0)
val currentIndex: LiveData<Int> get() = mCurrentIndex val index: LiveData<Int> get() = mIndex
private val mCurrentMode = MutableLiveData(PlaybackMode.ALL_SONGS)
val currentMode: LiveData<PlaybackMode> get() = mCurrentMode
private val mCurrentDuration = MutableLiveData(0L)
// States
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) private val mIsShuffling = MutableLiveData(false)
val isShuffling: LiveData<Boolean> get() = mIsShuffling val isShuffling: LiveData<Boolean> get() = mIsShuffling
private val mShuffleSeed = MutableLiveData(-1L) // Other
val shuffleSeed: LiveData<Long> get() = mShuffleSeed
private val mIsSeeking = MutableLiveData(false) private val mIsSeeking = MutableLiveData(false)
val isSeeking: LiveData<Boolean> get() = mIsSeeking 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 private var mCanAnimate = false
val canAnimate: Boolean get() = mCanAnimate val canAnimate: Boolean get() = mCanAnimate
// Service setup
private val playbackManager = PlaybackStateManager.getInstance()
private lateinit var playbackService: PlaybackService private lateinit var playbackService: PlaybackService
private var playbackIntent: Intent private var playbackIntent: Intent
@ -79,342 +84,137 @@ class PlaybackViewModel(private val context: Context) : ViewModel(), Player.Even
} }
} }
// Formatted variants of the duration init {
val formattedCurrentDuration = Transformations.map(mCurrentDuration) { playbackManager.addCallback(this)
it.toDuration()
} }
val formattedSeekBarProgress = Transformations.map(mCurrentDuration) { // --- PLAYING FUNCTIONS ---
if (mCurrentSong.value != null) it.toInt() else 0
fun playSong(song: Song, mode: PlaybackMode) {
playbackManager.playSong(song, mode)
} }
// Formatted queue that shows all the songs after the current playing song. fun shuffleAll() {
val formattedQueue = Transformations.map(mQueue) { playbackManager.shuffleAll()
it.slice((mCurrentIndex.value!! + 1) until it.size)
} }
// Update the current song while changing the queue mode. fun playAlbum(album: Album, shuffled: Boolean) {
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}")
if (album.songs.isEmpty()) { 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 return
} }
val songs = orderSongsInAlbum(album) playbackManager.playParentModel(album, shuffled)
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()
}
} }
fun play(artist: Artist, isShuffled: Boolean) { fun playArtist(artist: Artist, shuffled: Boolean) {
Log.d(this::class.simpleName, "Playing artist ${artist.name}")
if (artist.songs.isEmpty()) { 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 return
} }
val songs = orderSongsInArtist(artist) playbackManager.playParentModel(artist, shuffled)
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()
}
} }
fun play(genre: Genre, isShuffled: Boolean) { fun playGenre(genre: Genre, shuffled: Boolean) {
Log.d(this::class.simpleName, "Playing genre ${genre.name}")
if (genre.songs.isEmpty()) { 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 return
} }
val songs = orderSongsInGenre(genre) playbackManager.playParentModel(genre, shuffled)
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()
}
} }
// Update the current duration using a SeekBar progress // --- POSITION FUNCTIONS ---
fun updateCurrentDurationWithProgress(progress: Int) {
mCurrentDuration.value = progress.toLong() fun updatePositionWithProgress(progress: Int) {
playbackManager.setPosition(progress.toLong())
} }
// Invert, not directly set the playing/shuffling status // --- QUEUE FUNCTIONS ---
// Used by the toggle buttons in playback fragment.
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() { fun invertPlayingStatus() {
mCanAnimate = true mCanAnimate = true
mIsPlaying.value = !mIsPlaying.value!! playbackManager.setPlayingStatus(!playbackManager.isPlaying)
} }
fun invertShuffleStatus() { fun invertShuffleStatus() {
mIsShuffling.value = !mIsShuffling.value!! playbackManager.setShuffleStatus(!playbackManager.isShuffling)
if (mIsShuffling.value!!) {
genShuffle(true)
} else {
resetShuffle()
}
} }
// Shuffle all the songs. // --- OTHER FUNCTIONS ---
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()
}
fun resetAnimStatus() { fun resetAnimStatus() {
mCanAnimate = false mCanAnimate = false
} }
// Move two queue items. Note that this function does not force-update the queue, fun setSeekingStatus(value: Boolean) {
// as calling updateData with a drag would cause bugs. mIsSeeking.value = value
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() // --- OVERRIDES ---
}
// 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
}
override fun onCleared() { 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 { 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 import org.oxycblt.auxio.theme.toColor
class QueueFragment : BottomSheetDialogFragment() { 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 override fun getTheme(): Int = R.style.Theme_BottomSheetFix
@ -46,7 +48,7 @@ class QueueFragment : BottomSheetDialogFragment() {
// --- VIEWMODEL SETUP --- // --- 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 // If the first item is being moved, then scroll to the top position on completion
// to prevent ListAdapter from scrolling uncontrollably. // to prevent ListAdapter from scrolling uncontrollably.
if (queueAdapter.currentList.isNotEmpty() && it[0].id != queueAdapter.currentList[0].id) { 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. // Enum for instruction how the queue should function.
// ALL SONGS -> Play from all songs // 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.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.state.PlaybackMode
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.theme.applyDivider import org.oxycblt.auxio.theme.applyDivider
@ -42,7 +42,7 @@ class SongsFragment : Fragment() {
binding.songRecycler.apply { binding.songRecycler.apply {
adapter = SongAdapter(musicStore.songs) { adapter = SongAdapter(musicStore.songs) {
playbackModel.update(it, PlaybackMode.ALL_SONGS) playbackModel.playSong(it, PlaybackMode.ALL_SONGS)
} }
applyDivider() applyDivider()
setHasFixedSize(true) setHasFixedSize(true)