playback: implement new queue system

Implement a new heap-based queue system into the app.

Unlike the prior queue, this one no longer keeps one canonical queue
and then simply swaps between shuffled and ordered queues by
re-grabbing the song list currently being played. Instead, the same
"heap" of songs is used throughout, with only the way they are
interpreted changing.

This enables a bunch of near functionality that would not be possible
with the prior queue system, but is also really unstable and needs a
lot more testing.

Currently this commit disables state saving at the moment. It will be
re-enabled when the new queue can be reliably restored.
This commit is contained in:
Alexander Capehart 2023-01-04 19:08:45 -07:00
parent 1b44eeae15
commit 16513e6547
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 501 additions and 349 deletions

View file

@ -30,9 +30,9 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -29,6 +29,7 @@ import androidx.core.database.getStringOrNull
import java.io.File
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.directoryCompat
@ -36,7 +37,6 @@ import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.storage.safeQuery
import org.oxycblt.auxio.music.storage.storageVolumesCompat
import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD

View file

@ -23,8 +23,8 @@ import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW

View file

@ -26,10 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.state.*
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.context
@ -100,13 +97,18 @@ class PlaybackViewModel(application: Application) :
playbackManager.removeListener(this)
}
override fun onIndexMoved(index: Int) {
_song.value = playbackManager.song
override fun onIndexMoved(queue: Queue) {
_song.value = playbackManager.queue.currentSong
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
_song.value = playbackManager.song
override fun onQueueReworked(queue: Queue) {
_isShuffled.value = queue.isShuffled
}
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
_song.value = playbackManager.queue.currentSong
_parent.value = playbackManager.parent
_isShuffled.value = queue.isShuffled
}
override fun onStateChanged(state: InternalPlayer.State) {
@ -126,10 +128,6 @@ class PlaybackViewModel(application: Application) :
}
}
override fun onShuffledChanged(isShuffled: Boolean) {
_isShuffled.value = isShuffled
}
override fun onRepeatChanged(repeatMode: RepeatMode) {
_repeatMode.value = repeatMode
}
@ -141,21 +139,19 @@ class PlaybackViewModel(application: Application) :
* @param song The [Song] to play.
*/
fun playFromAll(song: Song) {
playbackManager.play(song, null, settings)
playImpl(song, null)
}
/** Shuffle all songs in the music library. */
fun shuffleAll() {
playbackManager.play(null, null, settings, true)
playImpl(null, null, true)
}
/**
* Play a [Song] from it's [Album].
* @param song The [Song] to play.
*/
fun playFromAlbum(song: Song) {
playbackManager.play(song, song.album, settings)
}
fun playFromAlbum(song: Song) = playImpl(song, song.album)
/**
* Play a [Song] from one of it's [Artist]s.
@ -165,10 +161,9 @@ class PlaybackViewModel(application: Application) :
*/
fun playFromArtist(song: Song, artist: Artist? = null) {
if (artist != null) {
check(artist in song.artists) { "Artist not in song artists" }
playbackManager.play(song, artist, settings)
playImpl(song, artist)
} else if (song.artists.size == 1) {
playbackManager.play(song, song.artists[0], settings)
playImpl(song, song.artists[0])
} else {
_artistPlaybackPickerSong.value = song
}
@ -191,10 +186,9 @@ class PlaybackViewModel(application: Application) :
*/
fun playFromGenre(song: Song, genre: Genre? = null) {
if (genre != null) {
check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" }
playbackManager.play(song, genre, settings)
playImpl(song, genre)
} else if (song.genres.size == 1) {
playbackManager.play(song, song.genres[0], settings)
playImpl(song, song.genres[0])
} else {
_genrePlaybackPickerSong.value = song
}
@ -204,49 +198,37 @@ class PlaybackViewModel(application: Application) :
* Play an [Album].
* @param album The [Album] to play.
*/
fun play(album: Album) {
playbackManager.play(null, album, settings, false)
}
fun play(album: Album) = playImpl(null, album, false)
/**
* Play an [Artist].
* @param artist The [Artist] to play.
*/
fun play(artist: Artist) {
playbackManager.play(null, artist, settings, false)
}
fun play(artist: Artist) = playImpl(null, artist, false)
/**
* Play a [Genre].
* @param genre The [Genre] to play.
*/
fun play(genre: Genre) {
playbackManager.play(null, genre, settings, false)
}
fun play(genre: Genre) = playImpl(null, genre, false)
/**
* Shuffle an [Album].
* @param album The [Album] to shuffle.
*/
fun shuffle(album: Album) {
playbackManager.play(null, album, settings, true)
}
fun shuffle(album: Album) = playImpl(null, album, true)
/**
* Shuffle an [Artist].
* @param artist The [Artist] to shuffle.
*/
fun shuffle(artist: Artist) {
playbackManager.play(null, artist, settings, true)
}
fun shuffle(artist: Artist) = playImpl(null, artist, true)
/**
* Shuffle an [Genre].
* @param genre The [Genre] to shuffle.
*/
fun shuffle(genre: Genre) {
playbackManager.play(null, genre, settings, true)
}
fun shuffle(genre: Genre) = playImpl(null, genre, true)
/**
* Start the given [InternalPlayer.Action] to be completed eventually. This can be used to
@ -257,6 +239,24 @@ class PlaybackViewModel(application: Application) :
playbackManager.startAction(action)
}
private fun playImpl(
song: Song?,
parent: MusicParent?,
shuffled: Boolean = playbackManager.queue.isShuffled && settings.keepShuffle
) {
check(song == null || parent == null || parent.songs.contains(song)) {
"Song to play not in parent"
}
val sort =
when (parent) {
is Genre -> settings.detailGenreSort
is Artist -> settings.detailArtistSort
is Album -> settings.detailAlbumSort
null -> settings.libSongSort
}
playbackManager.play(song, parent, sort, shuffled)
}
// --- PLAYER FUNCTIONS ---
/**
@ -370,7 +370,7 @@ class PlaybackViewModel(application: Application) :
/** Toggle [isShuffled] (ex. from on to off) */
fun invertShuffled() {
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
playbackManager.reorder(!playbackManager.queue.isShuffled)
}
/**

View file

@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Queue
/**
* A [ViewModel] that manages the current queue state and allows navigation through the queue.
@ -36,7 +37,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
/** The current queue. */
val queue: StateFlow<List<Song>> = _queue
private val _index = MutableStateFlow(playbackManager.index)
private val _index = MutableStateFlow(playbackManager.queue.index)
/** The index of the currently playing song in the queue. */
val index: StateFlow<Int>
get() = _index
@ -56,10 +57,6 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
* range.
*/
fun goto(adapterIndex: Int) {
if (adapterIndex !in playbackManager.queue.indices) {
// Invalid input. Nothing to do.
return
}
playbackManager.goto(adapterIndex)
}
@ -69,10 +66,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
* range.
*/
fun removeQueueDataItem(adapterIndex: Int) {
if (adapterIndex <= playbackManager.index ||
adapterIndex !in playbackManager.queue.indices) {
// Invalid input. Nothing to do.
// TODO: Allow editing played queue items.
if (adapterIndex !in queue.value.indices) {
return
}
playbackManager.removeQueueItem(adapterIndex)
@ -85,7 +79,8 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
* @return true if the items were moved, false otherwise.
*/
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean {
if (adapterFrom <= playbackManager.index || adapterTo <= playbackManager.index) {
if (adapterFrom <= playbackManager.queue.index ||
adapterTo <= playbackManager.queue.index) {
// Invalid input. Nothing to do.
return false
}
@ -103,34 +98,34 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
scrollTo = null
}
override fun onIndexMoved(index: Int) {
override fun onIndexMoved(queue: Queue) {
// Index moved -> Scroll to new index
replaceQueue = null
scrollTo = index
_index.value = index
scrollTo = queue.index
_index.value = queue.index
}
override fun onQueueChanged(queue: List<Song>) {
override fun onQueueChanged(queue: Queue) {
// Queue changed trivially due to item move -> Diff queue, stay at current index.
replaceQueue = false
scrollTo = null
_queue.value = playbackManager.queue.toMutableList()
_queue.value = queue.resolve()
}
override fun onQueueReworked(index: Int, queue: List<Song>) {
override fun onQueueReworked(queue: Queue) {
// Queue changed completely -> Replace queue, update index
replaceQueue = true
scrollTo = index
_queue.value = playbackManager.queue.toMutableList()
_index.value = index
scrollTo = queue.index
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
// Entirely new queue -> Replace queue, update index
replaceQueue = true
scrollTo = index
_queue.value = playbackManager.queue.toMutableList()
_index.value = index
scrollTo = queue.index
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onCleared() {

View file

@ -133,7 +133,7 @@ class ReplayGainAudioProcessor(private val context: Context) :
// User wants album gain to be used when in an album, track gain otherwise.
ReplayGainMode.DYNAMIC ->
playbackManager.parent is Album &&
playbackManager.song?.album == playbackManager.parent
playbackManager.queue.currentSong?.album == playbackManager.parent
}
val resolvedGain =

View file

@ -17,18 +17,11 @@
package org.oxycblt.auxio.playback.state
import kotlin.math.max
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
@ -59,22 +52,13 @@ class PlaybackStateManager private constructor() {
@Volatile private var pendingAction: InternalPlayer.Action? = null
@Volatile private var isInitialized = false
/** The currently playing [Song]. Null if nothing is playing. */
val song
get() = queue.getOrNull(index)
/** The current [Queue]. */
val queue = Queue()
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
@Volatile
var parent: MusicParent? = null
private set
@Volatile private var _queue = mutableListOf<Song>()
/** The current queue. */
val queue
get() = _queue
/** The position of the currently playing item in the queue. */
@Volatile
var index = -1
private set
/** The current [InternalPlayer] state. */
@Volatile
var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
@ -86,13 +70,8 @@ class PlaybackStateManager private constructor() {
field = value
notifyRepeatModeChanged()
}
/** Whether the queue is shuffled. */
@Volatile
var isShuffled = false
private set
/**
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
* available.
* The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable.
*/
val currentAudioSessionId: Int?
get() = internalPlayer?.audioSessionId
@ -106,9 +85,8 @@ class PlaybackStateManager private constructor() {
@Synchronized
fun addListener(listener: Listener) {
if (isInitialized) {
listener.onNewPlayback(index, queue, parent)
listener.onNewPlayback(queue, parent)
listener.onRepeatChanged(repeatMode)
listener.onShuffledChanged(isShuffled)
listener.onStateChanged(playerState)
}
@ -141,7 +119,7 @@ class PlaybackStateManager private constructor() {
}
if (isInitialized) {
internalPlayer.loadSong(song, playerState.isPlaying)
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
// See if there's any action that has been queued.
requestAction(internalPlayer)
@ -175,27 +153,19 @@ class PlaybackStateManager private constructor() {
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
* @param parent The [MusicParent] to play from, or null if to play from the entire
* [MusicStore.Library].
* @param settings [Settings] required to configure the queue.
* @param shuffled Whether to shuffle the queue. Defaults to the "Remember shuffle"
* configuration.
* @param sort [Sort] to initially sort an ordered queue with.
* @param shuffled Whether to shuffle or not.
*/
@Synchronized
fun play(
song: Song?,
parent: MusicParent?,
settings: Settings,
shuffled: Boolean = settings.keepShuffle && isShuffled
) {
fun play(song: Song?, parent: MusicParent?, sort: Sort, shuffled: Boolean) {
val internalPlayer = internalPlayer ?: return
val library = musicStore.library ?: return
// Setup parent and queue
// Set up parent and queue
this.parent = parent
_queue = (parent?.songs ?: library.songs).toMutableList()
orderQueue(settings, shuffled, song)
queue.start(song, sort.songs(parent?.songs ?: library.songs), shuffled)
// Notify components of changes
notifyNewPlayback()
notifyShuffledChanged()
internalPlayer.loadSong(this.song, true)
internalPlayer.loadSong(queue.currentSong, true)
// Played something, so we are initialized now
isInitialized = true
}
@ -209,13 +179,13 @@ class PlaybackStateManager private constructor() {
@Synchronized
fun next() {
val internalPlayer = internalPlayer ?: return
// Increment the index, if it cannot be incremented any further, then
// repeat and pause/resume playback depending on the setting
if (index < _queue.lastIndex) {
gotoImpl(internalPlayer, index + 1, true)
} else {
gotoImpl(internalPlayer, 0, repeatMode == RepeatMode.ALL)
var play = true
if (!queue.goto(queue.index + 1)) {
queue.goto(0)
play = false
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, play)
}
/**
@ -231,7 +201,11 @@ class PlaybackStateManager private constructor() {
rewind()
setPlaying(true)
} else {
gotoImpl(internalPlayer, max(index - 1, 0), true)
if (!queue.goto(queue.index - 1)) {
queue.goto(0)
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
}
@ -242,13 +216,10 @@ class PlaybackStateManager private constructor() {
@Synchronized
fun goto(index: Int) {
val internalPlayer = internalPlayer ?: return
gotoImpl(internalPlayer, index, true)
}
private fun gotoImpl(internalPlayer: InternalPlayer, idx: Int, play: Boolean) {
index = idx
notifyIndexMoved()
internalPlayer.loadSong(song, play)
if (queue.goto(index)) {
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
}
/**
@ -257,7 +228,7 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun playNext(song: Song) {
_queue.add(index + 1, song)
queue.playNext(listOf(song))
notifyQueueChanged()
}
@ -267,7 +238,7 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun playNext(songs: List<Song>) {
_queue.addAll(index + 1, songs)
queue.playNext(songs)
notifyQueueChanged()
}
@ -277,7 +248,7 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun addToQueue(song: Song) {
_queue.add(song)
queue.addToQueue(listOf(song))
notifyQueueChanged()
}
@ -287,82 +258,41 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun addToQueue(songs: List<Song>) {
_queue.addAll(songs)
queue.addToQueue(songs)
notifyQueueChanged()
}
/**
* Move a [Song] in the queue.
* @param from The position of the [Song] to move in the queue.
* @param to The destination position in the queue.
* @param src The position of the [Song] to move in the queue.
* @param dst The destination position in the queue.
*/
@Synchronized
fun moveQueueItem(from: Int, to: Int) {
logD("Moving item $from to position $to")
_queue.add(to, _queue.removeAt(from))
fun moveQueueItem(src: Int, dst: Int) {
logD("Moving item $src to position $dst")
queue.move(src, dst)
notifyQueueChanged()
}
/**
* Remove a [Song] from the queue.
* @param index The position of the [Song] to remove in the queue.
* @param at The position of the [Song] to remove in the queue.
*/
@Synchronized
fun removeQueueItem(index: Int) {
logD("Removing item ${_queue[index].rawName}")
_queue.removeAt(index)
fun removeQueueItem(at: Int) {
logD("Removing item at $at")
queue.remove(at)
notifyQueueChanged()
}
/**
* (Re)shuffle or (Re)order this instance.
* @param shuffled Whether to shuffle the queue or not.
* @param settings [Settings] required to configure the queue.
*/
@Synchronized
fun reshuffle(shuffled: Boolean, settings: Settings) {
val song = song ?: return
orderQueue(settings, shuffled, song)
fun reorder(shuffled: Boolean) {
queue.reorder(shuffled)
notifyQueueReworked()
notifyShuffledChanged()
}
/**
* Re-configure the queue.
* @param settings [Settings] required to configure the queue.
* @param shuffled Whether to shuffle the queue or not.
* @param keep the [Song] to start at in the new queue, or null if not specified.
*/
private fun orderQueue(settings: Settings, shuffled: Boolean, keep: Song?) {
val newIndex: Int
if (shuffled) {
// Shuffling queue, randomize the current song list and move the Song to play
// to the start.
_queue.shuffle()
if (keep != null) {
_queue.add(0, _queue.removeAt(_queue.indexOf(keep)))
}
newIndex = 0
} else {
// Ordering queue, re-sort it using the analogous parent sort configuration and
// then jump to the Song to play.
// TODO: Rework queue system to avoid having to do this
val sort =
parent.let { parent ->
when (parent) {
null -> settings.libSongSort
is Album -> settings.detailAlbumSort
is Artist -> settings.detailArtistSort
is Genre -> settings.detailGenreSort
}
}
sort.songsInPlace(_queue)
newIndex = keep?.let(_queue::indexOf) ?: 0
}
_queue = queue
index = newIndex
isShuffled = shuffled
}
// --- INTERNAL PLAYER FUNCTIONS ---
@ -379,7 +309,7 @@ class PlaybackStateManager private constructor() {
return
}
val newState = internalPlayer.getState(song?.durationMs ?: 0)
val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0)
if (newState != playerState) {
playerState = newState
notifyStateChanged()
@ -452,44 +382,49 @@ class PlaybackStateManager private constructor() {
return false
}
val library = musicStore.library ?: return false
val internalPlayer = internalPlayer ?: return false
val state =
try {
withContext(Dispatchers.IO) { database.read(library) }
} catch (e: Exception) {
logE("Unable to restore playback state.")
logE(e.stackTraceToString())
return false
}
// TODO: Re-implement with new queue
return false
// Translate the state we have just read into a usable playback state for this
// instance.
return synchronized(this) {
// State could have changed while we were loading, so check if we were initialized
// now before applying the state.
if (state != null && (!isInitialized || force)) {
index = state.index
parent = state.parent
_queue = state.queue.toMutableList()
repeatMode = state.repeatMode
isShuffled = state.isShuffled
notifyNewPlayback()
notifyRepeatModeChanged()
notifyShuffledChanged()
// Continuing playback after drastic state updates is a bad idea, so pause.
internalPlayer.loadSong(song, false)
internalPlayer.seekTo(state.positionMs)
isInitialized = true
true
} else {
false
}
}
// val library = musicStore.library ?: return false
// val internalPlayer = internalPlayer ?: return false
// val state =
// try {
// withContext(Dispatchers.IO) { database.read(library) }
// } catch (e: Exception) {
// logE("Unable to restore playback state.")
// logE(e.stackTraceToString())
// return false
// }
//
// // Translate the state we have just read into a usable playback state for this
// // instance.
// return synchronized(this) {
// // State could have changed while we were loading, so check if we were
// initialized
// // now before applying the state.
// if (state != null && (!isInitialized || force)) {
// index = state.index
// parent = state.parent
// _queue = state.queue.toMutableList()
// repeatMode = state.repeatMode
// isShuffled = state.isShuffled
//
// notifyNewPlayback()
// notifyRepeatModeChanged()
// notifyShuffledChanged()
//
// // Continuing playback after drastic state updates is a bad idea, so
// pause.
// internalPlayer.loadSong(song, false)
// internalPlayer.seekTo(state.positionMs)
//
// isInitialized = true
//
// true
// } else {
// false
// }
// }
}
/**
@ -499,26 +434,26 @@ class PlaybackStateManager private constructor() {
*/
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
logD("Saving state to DB")
// Create the saved state from the current playback state.
val state =
synchronized(this) {
PlaybackStateDatabase.SavedState(
index = index,
parent = parent,
queue = _queue,
positionMs = playerState.calculateElapsedPositionMs(),
isShuffled = isShuffled,
repeatMode = repeatMode)
}
return try {
withContext(Dispatchers.IO) { database.write(state) }
true
} catch (e: Exception) {
logE("Unable to save playback state.")
logE(e.stackTraceToString())
false
}
return false
// // Create the saved state from the current playback state.
// val state =
// synchronized(this) {
// PlaybackStateDatabase.SavedState(
// index = index,
// parent = parent,
// queue = _queue,
// positionMs = playerState.calculateElapsedPositionMs(),
// isShuffled = isShuffled,
// repeatMode = repeatMode)
// }
// return try {
// withContext(Dispatchers.IO) { database.write(state) }
// true
// } catch (e: Exception) {
// logE("Unable to save playback state.")
// logE(e.stackTraceToString())
// false
// }
}
/**
@ -543,54 +478,57 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun sanitize(newLibrary: MusicStore.Library) {
if (!isInitialized) {
// Nothing playing, nothing to do.
logD("Not initialized, no need to sanitize")
return
}
val internalPlayer = internalPlayer ?: return
logD("Sanitizing state")
// While we could just save and reload the state, we instead sanitize the state
// at runtime for better performance (and to sidestep a co-routine on behalf of the caller).
// Sanitize parent
parent =
parent?.let {
when (it) {
is Album -> newLibrary.sanitize(it)
is Artist -> newLibrary.sanitize(it)
is Genre -> newLibrary.sanitize(it)
}
}
// Sanitize queue. Make sure we re-align the index to point to the previously playing
// Song in the queue queue.
val oldSongUid = song?.uid
_queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) }
while (song?.uid != oldSongUid && index > -1) {
index--
}
notifyNewPlayback()
val oldPosition = playerState.calculateElapsedPositionMs()
// Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause.
internalPlayer.loadSong(song, false)
if (index > -1) {
// Internal player may have reloaded the media item, re-seek to the previous position
seekTo(oldPosition)
}
// if (!isInitialized) {
// // Nothing playing, nothing to do.
// logD("Not initialized, no need to sanitize")
// return
// }
//
// val internalPlayer = internalPlayer ?: return
//
// logD("Sanitizing state")
//
// // While we could just save and reload the state, we instead sanitize the state
// // at runtime for better performance (and to sidestep a co-routine on behalf of
// the caller).
//
// // Sanitize parent
// parent =
// parent?.let {
// when (it) {
// is Album -> newLibrary.sanitize(it)
// is Artist -> newLibrary.sanitize(it)
// is Genre -> newLibrary.sanitize(it)
// }
// }
//
// // Sanitize queue. Make sure we re-align the index to point to the previously
// playing
// // Song in the queue queue.
// val oldSongUid = song?.uid
// _queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) }
// while (song?.uid != oldSongUid && index > -1) {
// index--
// }
//
// notifyNewPlayback()
//
// val oldPosition = playerState.calculateElapsedPositionMs()
// // Continuing playback while also possibly doing drastic state updates is
// // a bad idea, so pause.
// internalPlayer.loadSong(song, false)
// if (index > -1) {
// // Internal player may have reloaded the media item, re-seek to the previous
// position
// seekTo(oldPosition)
// }
}
// --- CALLBACKS ---
private fun notifyIndexMoved() {
for (callback in listeners) {
callback.onIndexMoved(index)
callback.onIndexMoved(queue)
}
}
@ -602,13 +540,13 @@ class PlaybackStateManager private constructor() {
private fun notifyQueueReworked() {
for (callback in listeners) {
callback.onQueueReworked(index, queue)
callback.onQueueReworked(queue)
}
}
private fun notifyNewPlayback() {
for (callback in listeners) {
callback.onNewPlayback(index, queue, parent)
callback.onNewPlayback(queue, parent)
}
}
@ -624,12 +562,6 @@ class PlaybackStateManager private constructor() {
}
}
private fun notifyShuffledChanged() {
for (callback in listeners) {
callback.onShuffledChanged(isShuffled)
}
}
/**
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
* [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener].
@ -638,30 +570,29 @@ class PlaybackStateManager private constructor() {
/**
* Called when the position of the currently playing item has changed, changing the current
* [Song], but no other queue attribute has changed.
* @param index The new position in the queue.
* @param queue The new [Queue].
*/
fun onIndexMoved(index: Int) {}
fun onIndexMoved(queue: Queue) {}
/**
* Called when the queue changed in a trivial manner, such as a move.
* @param queue The new queue.
* Called when the [Queue] changed in a trivial manner, such as a move.
* @param queue The new [Queue].
*/
fun onQueueChanged(queue: List<Song>) {}
fun onQueueChanged(queue: Queue) {}
/**
* Called when the queue has changed in a non-trivial manner (such as re-shuffling), but the
* currently playing [Song] has not.
* @param index The new position in the queue.
* Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but
* the currently playing [Song] has not.
* @param queue The new [Queue].
*/
fun onQueueReworked(index: Int, queue: List<Song>) {}
fun onQueueReworked(queue: Queue) {}
/**
* Called when a new playback configuration was created.
* @param index The new position in the queue.
* @param queue The new queue.
* @param queue The new [Queue].
* @param parent The new [MusicParent] being played from, or null if playing from all songs.
*/
fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {}
fun onNewPlayback(queue: Queue, parent: MusicParent?) {}
/**
* Called when the state of the [InternalPlayer] changes.
@ -674,13 +605,6 @@ class PlaybackStateManager private constructor() {
* @param repeatMode The new [RepeatMode].
*/
fun onRepeatChanged(repeatMode: RepeatMode) {}
/**
* Called when the queue's shuffle state changes. Handling the queue change itself should
* occur in [onQueueReworked],
* @param isShuffled Whether the queue is shuffled.
*/
fun onShuffledChanged(isShuffled: Boolean) {}
}
companion object {

View file

@ -0,0 +1,235 @@
/*
* Copyright (c) 2023 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.state
import kotlin.random.Random
import kotlin.random.nextInt
import org.oxycblt.auxio.music.Song
/**
* A heap-backed play queue.
*
* Whereas other queue implementations use a plain list, Auxio requires a more complicated data
* structure in order to implement features such as gapless playback in ExoPlayer. This queue
* implementation is instead based around an unorganized "heap" of [Song] instances, that are then
* interpreted into different queues depending on the current playback configuration.
*
* In general, the implementation details don't need ot be known for this data structure to be used.
* The functions exposed should be familiar for any typical play queue.
*
* @author OxygenCobalt
*/
class Queue {
@Volatile private var heap = mutableListOf<Song>()
@Volatile private var orderedMapping = mutableListOf<Int>()
@Volatile private var shuffledMapping = mutableListOf<Int>()
/** The index of the currently playing [Song] in the current mapping. */
@Volatile
var index = 0
private set
/** The currently playing [Song]. */
val currentSong: Song?
get() = shuffledMapping.ifEmpty { orderedMapping.ifEmpty { null } }?.let { heap[it[index]] }
/** Whether this queue is shuffled. */
val isShuffled: Boolean
get() = shuffledMapping.isNotEmpty()
/**
* Resolve this queue into a more conventional list of [Song]s.
* @return A list of [Song] corresponding to the current queue mapping.
*/
fun resolve() = shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } }
/**
* Go to a particular index in the queue.
* @param to The index of the [Song] to start playing, in the current queue mapping.
* @return true if the queue jumped to that position, false otherwise.
*/
fun goto(to: Int): Boolean {
if (to !in orderedMapping.indices) {
return false
}
index = to
return true
}
/**
* Start a new queue configuration.
* @param song The [Song] to play, or null to start from a random position.
* @param queue The queue of [Song]s to play. Must contain [song]. This list will become the
* heap internally.
* @param shuffled Whether to shuffle the queue or not. This changes the interpretation of
* [queue].
*/
fun start(song: Song?, queue: List<Song>, shuffled: Boolean) {
heap = queue.toMutableList()
orderedMapping = MutableList(queue.size) { it }
shuffledMapping = mutableListOf()
index =
song?.let(queue::indexOf) ?: if (shuffled) Random.Default.nextInt(queue.indices) else 0
reorder(shuffled)
}
/**
* Re-order the queue.
* @param shuffled Whether the queue should be shuffled or not.
*/
fun reorder(shuffled: Boolean) {
if (shuffled) {
val trueIndex =
if (shuffledMapping.isNotEmpty()) {
// Re-shuffling, song to preserve is in the shuffled mapping
shuffledMapping[index]
} else {
// First shuffle, song to preserve is in the ordered mapping
orderedMapping[index]
}
// Since we are re-shuffling existing songs, we use the previous mapping size
// instead of the total queue size.
shuffledMapping = MutableList(orderedMapping.size) { it }.apply { shuffle() }
shuffledMapping.add(0, shuffledMapping.removeAt(shuffledMapping.indexOf(trueIndex)))
index = 0
} else if (shuffledMapping.isNotEmpty()) {
// Un-shuffling, song to preserve is in the shuffled mapping.
index = orderedMapping.indexOf(shuffledMapping[index])
shuffledMapping = mutableListOf()
}
}
/**
* Add [Song]s to the top of the queue. Will start playback if nothing is playing.
* @param songs The [Song]s to add.
*/
fun playNext(songs: List<Song>) {
if (orderedMapping.isEmpty()) {
// No playback, start playing these songs.
start(songs[0], songs, false)
return
}
val heapIndices = songs.map(::addSongToHeap)
if (shuffledMapping.isNotEmpty()) {
// Add the new songs in front of the current index in the shuffled mapping and in front
// of the analogous list song in the ordered mapping.
val orderedIndex = orderedMapping.indexOf(shuffledMapping[index])
orderedMapping.addAll(orderedIndex, heapIndices)
shuffledMapping.addAll(index, heapIndices)
} else {
// Add the new song in front of the current index in the ordered mapping.
orderedMapping.addAll(index, heapIndices)
}
}
/**
* Add [Song]s to the end of the queue. Will start playback if nothing is playing.
* @param songs The [Song]s to add.
*/
fun addToQueue(songs: List<Song>) {
if (orderedMapping.isEmpty()) {
// No playback, start playing these songs.
start(songs[0], songs, false)
return
}
val heapIndices = songs.map(::addSongToHeap)
// Can simple append the new songs to the end of both mappings.
orderedMapping.addAll(heapIndices)
if (shuffledMapping.isNotEmpty()) {
shuffledMapping.addAll(heapIndices)
}
}
/**
* Move a [Song] at the given position to a new position.
* @param src The position of the [Song] to move.
* @param dst The destination position of the [Song].
*/
fun move(src: Int, dst: Int) {
if (shuffledMapping.isNotEmpty()) {
// Move songs only in the shuffled mapping. There is no sane analogous form of
// this for the ordered mapping.
shuffledMapping.add(dst, shuffledMapping.removeAt(src))
} else {
// Move songs in the ordered mapping.
orderedMapping.add(dst, orderedMapping.removeAt(src))
}
if (index in (src + 1) until dst) {
// Index was ahead of moved song but not ahead of it's destination position.
// This makes it functionally a removal, so update the index to preserve consistency.
index -= 1
} else if (index == src) {
// Moving the currently playing song.
index = dst
}
}
/**
* Remove a [Song] at the given position.
* @param at The position of the [Song] to remove.
*/
fun remove(at: Int) {
if (shuffledMapping.isNotEmpty()) {
// Remove the specified index in the shuffled mapping and the analogous song in the
// ordered mapping.
orderedMapping.removeAt(orderedMapping.indexOf(shuffledMapping[at]))
shuffledMapping.removeAt(at)
} else {
// Remove the spe
orderedMapping.removeAt(at)
}
// Note: We do not clear songs out from the heap, as that would require the backing data
// of the player to be completely invalidated. It's generally easier to not remove the
// song and retain player state consistency.
if (index > at) {
// Index was ahead of removed song, shift back to preserve consistency.
index -= 1
}
}
private fun addSongToHeap(song: Song): Int {
// We want to first try to see if there are any "orphaned" songs in the queue
// that we can re-use. This way, we can reduce the memory used up by songs that
// were previously removed from the queue.
val currentMapping = orderedMapping
if (orderedMapping.isNotEmpty()) {
// While we could iterate through the queue and then check the mapping, it's
// faster if we first check the queue for all instances of this song, and then
// do a exclusion of this set of indices with the current mapping in order to
// obtain the orphaned songs.
val orphanCandidates = mutableSetOf<Int>()
for (entry in heap.withIndex()) {
if (entry.value == song) {
orphanCandidates.add(entry.index)
}
}
orphanCandidates.removeAll(currentMapping.toSet())
if (orphanCandidates.isNotEmpty()) {
// There are orphaned songs, return the first one we find.
return orphanCandidates.first()
}
}
// Nothing to re-use, add this song to the queue
heap.add(song)
return heap.lastIndex
}
}

View file

@ -31,7 +31,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
class MediaButtonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val playbackManager = PlaybackStateManager.getInstance()
if (playbackManager.song != null) {
if (playbackManager.queue.currentSong != null) {
// We have a song, so we can assume that the service will start a foreground state.
// At least, I hope. Again, *this is why we don't do this*. I cannot describe how
// stupid this is with the state of foreground services on modern android. One

View file

@ -36,6 +36,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Queue
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
@ -92,22 +93,29 @@ class MediaSessionComponent(private val context: Context, private val listener:
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
override fun onIndexMoved(index: Int) {
updateMediaMetadata(playbackManager.song, playbackManager.parent)
override fun onIndexMoved(queue: Queue) {
updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent)
invalidateSessionState()
}
override fun onQueueChanged(queue: List<Song>) {
override fun onQueueChanged(queue: Queue) {
updateQueue(queue)
}
override fun onQueueReworked(index: Int, queue: List<Song>) {
override fun onQueueReworked(queue: Queue) {
updateQueue(queue)
invalidateSessionState()
mediaSession.setShuffleMode(
if (queue.isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
invalidateSecondaryAction()
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
updateMediaMetadata(playbackManager.song, parent)
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
updateMediaMetadata(playbackManager.queue.currentSong, parent)
updateQueue(queue)
invalidateSessionState()
}
@ -131,23 +139,12 @@ class MediaSessionComponent(private val context: Context, private val listener:
invalidateSecondaryAction()
}
override fun onShuffledChanged(isShuffled: Boolean) {
mediaSession.setShuffleMode(
if (isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
invalidateSecondaryAction()
}
// --- SETTINGS OVERRIDES ---
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
context.getString(R.string.set_key_cover_mode) ->
updateMediaMetadata(playbackManager.song, playbackManager.parent)
updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent)
context.getString(R.string.set_key_notif_action) -> invalidateSecondaryAction()
}
}
@ -219,16 +216,13 @@ class MediaSessionComponent(private val context: Context, private val listener:
}
override fun onSetShuffleMode(shuffleMode: Int) {
playbackManager.reshuffle(
playbackManager.reorder(
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP,
settings)
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP)
}
override fun onSkipToQueueItem(id: Long) {
if (id in playbackManager.queue.indices) {
playbackManager.goto(id.toInt())
}
playbackManager.goto(id.toInt())
}
override fun onCustomAction(action: String?, extras: Bundle?) {
@ -318,9 +312,9 @@ class MediaSessionComponent(private val context: Context, private val listener:
* Upload a new queue to the [MediaSessionCompat].
* @param queue The current queue to upload.
*/
private fun updateQueue(queue: List<Song>) {
private fun updateQueue(queue: Queue) {
val queueItems =
queue.mapIndexed { i, song ->
queue.resolve().mapIndexed { i, song ->
val description =
MediaDescriptionCompat.Builder()
// Media ID should not be the item index but rather the UID,
@ -350,7 +344,7 @@ class MediaSessionComponent(private val context: Context, private val listener:
.intoPlaybackState(PlaybackStateCompat.Builder())
.setActions(ACTIONS)
// Active queue ID corresponds to the indices we populated prior, use them here.
.setActiveQueueItemId(playbackManager.index.toLong())
.setActiveQueueItemId(playbackManager.queue.index.toLong())
// Android 13+ relies on custom actions in the notification.
@ -361,7 +355,7 @@ class MediaSessionComponent(private val context: Context, private val listener:
PlaybackStateCompat.CustomAction.Builder(
PlaybackService.ACTION_INVERT_SHUFFLE,
context.getString(R.string.desc_shuffle),
if (playbackManager.isShuffled) {
if (playbackManager.queue.isShuffled) {
R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
@ -391,7 +385,7 @@ class MediaSessionComponent(private val context: Context, private val listener:
invalidateSessionState()
when (settings.playbackNotificationAction) {
ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.isShuffled)
ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.queue.isShuffled)
else -> notification.updateRepeatMode(playbackManager.repeatMode)
}

View file

@ -351,12 +351,16 @@ class PlaybackService :
}
// Shuffle all -> Start new playback from all songs
is InternalPlayer.Action.ShuffleAll -> {
playbackManager.play(null, null, settings, true)
playbackManager.play(null, null, settings.libSongSort, true)
}
// Open -> Try to find the Song for the given file and then play it from all songs
is InternalPlayer.Action.Open -> {
library.findSongForUri(application, action.uri)?.let { song ->
playbackManager.play(song, null, settings)
playbackManager.play(
song,
null,
settings.libSongSort,
playbackManager.queue.isShuffled && settings.keepShuffle)
}
}
}
@ -411,8 +415,7 @@ class PlaybackService :
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
ACTION_INC_REPEAT_MODE ->
playbackManager.repeatMode = playbackManager.repeatMode.increment()
ACTION_INVERT_SHUFFLE ->
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
ACTION_INVERT_SHUFFLE -> playbackManager.reorder(!playbackManager.queue.isShuffled)
ACTION_SKIP_PREV -> playbackManager.prev()
ACTION_SKIP_NEXT -> playbackManager.next()
ACTION_EXIT -> {
@ -428,7 +431,7 @@ class PlaybackService :
// which would result in unexpected playback. Work around it by dropping the first
// call to this function, which should come from that Intent.
if (settings.headsetAutoplay &&
playbackManager.song != null &&
playbackManager.queue.currentSong != null &&
initialHeadsetPlugEventHandled) {
logD("Device connected, resuming")
playbackManager.setPlaying(true)
@ -436,7 +439,7 @@ class PlaybackService :
}
private fun pauseFromHeadsetPlug() {
if (playbackManager.song != null) {
if (playbackManager.queue.currentSong != null) {
logD("Device disconnected, pausing")
playbackManager.setPlaying(false)
}

View file

@ -30,6 +30,7 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Queue
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getDimenPixels
@ -55,7 +56,7 @@ class WidgetComponent(private val context: Context) :
/** Update [WidgetProvider] with the current playback state. */
fun update() {
val song = playbackManager.song
val song = playbackManager.queue.currentSong
if (song == null) {
logD("No song, resetting widget")
widgetProvider.update(context, null)
@ -65,7 +66,7 @@ class WidgetComponent(private val context: Context) :
// Note: Store these values here so they remain consistent once the bitmap is loaded.
val isPlaying = playbackManager.playerState.isPlaying
val repeatMode = playbackManager.repeatMode
val isShuffled = playbackManager.isShuffled
val isShuffled = playbackManager.queue.isShuffled
provider.load(
song,
@ -115,10 +116,10 @@ class WidgetComponent(private val context: Context) :
// Hook all the major song-changing updates + the major player state updates
// to updating the "Now Playing" widget.
override fun onIndexMoved(index: Int) = update()
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = update()
override fun onIndexMoved(queue: Queue) = update()
override fun onQueueReworked(queue: Queue) = update()
override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update()
override fun onStateChanged(state: InternalPlayer.State) = update()
override fun onShuffledChanged(isShuffled: Boolean) = update()
override fun onRepeatChanged(repeatMode: RepeatMode) = update()
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {