Refactor playback management
Move playback management from PlaybackViewModel to a dedicated class, update naming of playback functions heavily.
This commit is contained in:
parent
25142bba48
commit
42325c456e
14 changed files with 523 additions and 348 deletions
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
|
@ -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) {}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue