playback: re-add state persistence

Re-add state persistence with support for the new queue.

This should finally finish the new queue system.
This commit is contained in:
Alexander Capehart 2023-01-09 13:53:37 -07:00
parent 9bd78bc855
commit 692839e8fe
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 210 additions and 170 deletions

View file

@ -77,6 +77,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
override fun onDestroyBinding(binding: FragmentQueueBinding) {
super.onDestroyBinding(binding)
touchHelper = null
binding.queueRecycler.adapter = null
}

View file

@ -22,6 +22,7 @@ import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.provider.BaseColumns
import androidx.core.database.getIntOrNull
import androidx.core.database.sqlite.transaction
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library
@ -40,17 +41,22 @@ class PlaybackStateDatabase private constructor(context: Context) :
// of the non-queue parts of the state, such as the playback position.
db.createTable(TABLE_STATE) {
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
append("${StateColumns.INDEX} INTEGER NOT NULL,")
append("${StateColumns.POSITION} LONG NOT NULL,")
append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL,")
append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,")
append("${StateColumns.SONG_UID} STRING,")
append("${StateColumns.PARENT_UID} STRING")
append("${PlaybackStateColumns.INDEX} INTEGER NOT NULL,")
append("${PlaybackStateColumns.POSITION} LONG NOT NULL,")
append("${PlaybackStateColumns.REPEAT_MODE} INTEGER NOT NULL,")
append("${PlaybackStateColumns.SONG_UID} STRING,")
append("${PlaybackStateColumns.PARENT_UID} STRING")
}
db.createTable(TABLE_QUEUE) {
db.createTable(TABLE_QUEUE_HEAP) {
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
append("${QueueColumns.SONG_UID} STRING NOT NULL")
append("${QueueHeapColumns.SONG_UID} STRING NOT NULL")
}
db.createTable(TABLE_QUEUE_MAPPINGS) {
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
append("${QueueMappingColumns.ORDERED_INDEX} INT NOT NULL,")
append("${QueueMappingColumns.SHUFFLED_INDEX} INT")
}
}
@ -61,7 +67,8 @@ class PlaybackStateDatabase private constructor(context: Context) :
logD("Nuking database")
db.apply {
execSQL("DROP TABLE IF EXISTS $TABLE_STATE")
execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE")
execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_HEAP")
execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_MAPPINGS")
onCreate(this)
}
}
@ -77,63 +84,78 @@ class PlaybackStateDatabase private constructor(context: Context) :
requireBackgroundThread()
// Read the saved state and queue. If the state is non-null, that must imply an
// existent, albeit possibly empty, queue.
val rawState = readRawState() ?: return null
val queue = readQueue(library)
// Correct the index to match up with a queue that has possibly been shortened due to
// song removals.
var actualIndex = rawState.index
while (queue.getOrNull(actualIndex)?.uid != rawState.songUid && actualIndex > -1) {
actualIndex--
}
val rawState = readRawPlaybackState() ?: return null
val rawQueueState = readRawQueueState(library)
// Restore parent item from the music library. If this fails, then the playback mode
// reverts to "All Songs", which is considered okay.
val parent = rawState.parentUid?.let { library.find<MusicParent>(it) }
return SavedState(
index = actualIndex,
parent = parent,
queue = queue,
queueState =
Queue.SavedState(
heap = rawQueueState.heap,
orderedMapping = rawQueueState.orderedMapping,
shuffledMapping = rawQueueState.shuffledMapping,
index = rawState.index,
songUid = rawState.songUid),
positionMs = rawState.positionMs,
repeatMode = rawState.repeatMode,
isShuffled = rawState.isShuffled)
repeatMode = rawState.repeatMode)
}
private fun readRawState() =
private fun readRawPlaybackState() =
readableDatabase.queryAll(TABLE_STATE) { cursor ->
if (!cursor.moveToFirst()) {
// Empty, nothing to do.
return@queryAll null
}
val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX)
val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION)
val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE)
val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED)
val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID)
val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID)
RawState(
val indexIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.INDEX)
val posIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.POSITION)
val repeatModeIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.REPEAT_MODE)
val songUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.SONG_UID)
val parentUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.PARENT_UID)
RawPlaybackState(
index = cursor.getInt(indexIndex),
positionMs = cursor.getLong(posIndex),
repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex))
?: RepeatMode.NONE,
isShuffled = cursor.getInt(shuffleIndex) == 1,
songUid = Music.UID.fromString(cursor.getString(songUidIndex))
?: return@queryAll null,
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
}
private fun readQueue(library: Library): List<Song> {
val queue = mutableListOf<Song>()
readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
private fun readRawQueueState(library: Library): RawQueueState {
val heap = mutableListOf<Song?>()
readableDatabase.queryAll(TABLE_QUEUE_HEAP) { cursor ->
if (cursor.count == 0) {
// Empty, nothing to do.
return@queryAll
}
val songIndex = cursor.getColumnIndexOrThrow(QueueHeapColumns.SONG_UID)
while (cursor.moveToNext()) {
val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue
val song = library.find<Song>(uid) ?: continue
queue.add(song)
heap.add(Music.UID.fromString(cursor.getString(songIndex))?.let(library::find))
}
}
logD("Successfully read queue of ${heap.size} songs")
val orderedMapping = mutableListOf<Int?>()
val shuffledMapping = mutableListOf<Int?>()
readableDatabase.queryAll(TABLE_QUEUE_MAPPINGS) { cursor ->
if (cursor.count == 0) {
// Empty, nothing to do.
return@queryAll
}
val orderedIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.ORDERED_INDEX)
val shuffledIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.SHUFFLED_INDEX)
while (cursor.moveToNext()) {
orderedMapping.add(cursor.getInt(orderedIndex))
cursor.getIntOrNull(shuffledIndex)?.let(shuffledMapping::add)
}
}
logD("Successfully read queue of ${queue.size} songs")
return queue
return RawQueueState(heap, orderedMapping.filterNotNull(), shuffledMapping.filterNotNull())
}
/**
@ -144,40 +166,43 @@ class PlaybackStateDatabase private constructor(context: Context) :
requireBackgroundThread()
// Only bother saving a state if a song is actively playing from one.
// This is not the case with a null state or a state with an out-of-bounds index.
if (state != null && state.index in state.queue.indices) {
if (state != null) {
// Transform saved state into raw state, which can then be written to the database.
val rawState =
RawState(
index = state.index,
val rawPlaybackState =
RawPlaybackState(
index = state.queueState.index,
positionMs = state.positionMs,
repeatMode = state.repeatMode,
isShuffled = state.isShuffled,
songUid = state.queue[state.index].uid,
songUid = state.queueState.songUid,
parentUid = state.parent?.uid)
writeRawState(rawState)
writeQueue(state.queue)
writeRawPlaybackState(rawPlaybackState)
val rawQueueState =
RawQueueState(
heap = state.queueState.heap,
orderedMapping = state.queueState.orderedMapping,
shuffledMapping = state.queueState.shuffledMapping)
writeRawQueueState(rawQueueState)
logD("Wrote state")
} else {
writeRawState(null)
writeQueue(null)
writeRawPlaybackState(null)
writeRawQueueState(null)
logD("Cleared state")
}
}
private fun writeRawState(rawState: RawState?) {
private fun writeRawPlaybackState(rawPlaybackState: RawPlaybackState?) {
writableDatabase.transaction {
delete(TABLE_STATE, null, null)
if (rawState != null) {
if (rawPlaybackState != null) {
val stateData =
ContentValues(7).apply {
put(BaseColumns._ID, 0)
put(StateColumns.SONG_UID, rawState.songUid.toString())
put(StateColumns.POSITION, rawState.positionMs)
put(StateColumns.PARENT_UID, rawState.parentUid?.toString())
put(StateColumns.INDEX, rawState.index)
put(StateColumns.IS_SHUFFLED, rawState.isShuffled)
put(StateColumns.REPEAT_MODE, rawState.repeatMode.intCode)
put(PlaybackStateColumns.SONG_UID, rawPlaybackState.songUid.toString())
put(PlaybackStateColumns.POSITION, rawPlaybackState.positionMs)
put(PlaybackStateColumns.PARENT_UID, rawPlaybackState.parentUid?.toString())
put(PlaybackStateColumns.INDEX, rawPlaybackState.index)
put(PlaybackStateColumns.REPEAT_MODE, rawPlaybackState.repeatMode.intCode)
}
insert(TABLE_STATE, null, stateData)
@ -185,47 +210,54 @@ class PlaybackStateDatabase private constructor(context: Context) :
}
}
private fun writeQueue(queue: List<Song>?) {
writableDatabase.writeList(queue ?: listOf(), TABLE_QUEUE) { i, song ->
private fun writeRawQueueState(rawQueueState: RawQueueState?) {
writableDatabase.writeList(rawQueueState?.heap ?: listOf(), TABLE_QUEUE_HEAP) { i, song ->
ContentValues(2).apply {
put(BaseColumns._ID, i)
put(QueueColumns.SONG_UID, song.uid.toString())
put(QueueHeapColumns.SONG_UID, unlikelyToBeNull(song).uid.toString())
}
}
val combinedMapping =
rawQueueState?.run {
if (shuffledMapping.isNotEmpty()) {
orderedMapping.zip(shuffledMapping)
} else {
orderedMapping.map { Pair(it, null) }
}
}
writableDatabase.writeList(combinedMapping ?: listOf(), TABLE_QUEUE_MAPPINGS) { i, pair ->
ContentValues(3).apply {
put(BaseColumns._ID, i)
put(QueueMappingColumns.ORDERED_INDEX, pair.first)
put(QueueMappingColumns.SHUFFLED_INDEX, pair.second)
}
}
}
/**
* A condensed representation of the playback state that can be persisted.
* @param index The position of the currently playing item in the queue. Can be -1 if the
* persisted index no longer exists.
* @param queue The [Song] queue.
* @param parent The [MusicParent] item currently being played from
* @param parent The [MusicParent] item currently being played from.
* @param queueState The [Queue.SavedState]
* @param positionMs The current position in the currently played song, in ms
* @param repeatMode The current [RepeatMode].
* @param isShuffled Whether the queue is shuffled or not.
*/
data class SavedState(
val index: Int,
val queue: List<Song>,
val parent: MusicParent?,
val queueState: Queue.SavedState,
val positionMs: Long,
val repeatMode: RepeatMode,
val isShuffled: Boolean
)
/**
* A lower-level form of [SavedState] that contains additional information to create a more
* reliable restoration process.
*/
private data class RawState(
/** @see SavedState.index */
/** A lower-level form of [SavedState] that contains individual field-based information. */
private data class RawPlaybackState(
/** @see Queue.SavedState.index */
val index: Int,
/** @see SavedState.positionMs */
val positionMs: Long,
/** @see SavedState.repeatMode */
val repeatMode: RepeatMode,
/** @see SavedState.isShuffled */
val isShuffled: Boolean,
/**
* The [Music.UID] of the [Song] that was originally in the queue at [index]. This can be
* used to restore the currently playing item in the queue if the index mapping changed.
@ -235,33 +267,50 @@ class PlaybackStateDatabase private constructor(context: Context) :
val parentUid: Music.UID?
)
/** A lower-level form of [Queue.SavedState] that contains heap and mapping information. */
private data class RawQueueState(
/** @see Queue.SavedState.heap */
val heap: List<Song?>,
/** @see Queue.SavedState.orderedMapping */
val orderedMapping: List<Int>,
/** @see Queue.SavedState.shuffledMapping */
val shuffledMapping: List<Int>
)
/** Defines the columns used in the playback state table. */
private object StateColumns {
/** @see RawState.index */
private object PlaybackStateColumns {
/** @see RawPlaybackState.index */
const val INDEX = "queue_index"
/** @see RawState.positionMs */
/** @see RawPlaybackState.positionMs */
const val POSITION = "position"
/** @see RawState.isShuffled */
const val IS_SHUFFLED = "is_shuffling"
/** @see RawState.repeatMode */
/** @see RawPlaybackState.repeatMode */
const val REPEAT_MODE = "repeat_mode"
/** @see RawState.songUid */
/** @see RawPlaybackState.songUid */
const val SONG_UID = "song_uid"
/** @see RawState.parentUid */
/** @see RawPlaybackState.parentUid */
const val PARENT_UID = "parent"
}
/** Defines the columns used in the queue table. */
private object QueueColumns {
/** Defines the columns used in the queue heap table. */
private object QueueHeapColumns {
/** @see Music.UID */
const val SONG_UID = "song_uid"
}
/** Defines the columns used in the queue mapping table. */
private object QueueMappingColumns {
/** @see Queue.SavedState.orderedMapping */
const val ORDERED_INDEX = "ordered_index"
/** @see Queue.SavedState.shuffledMapping */
const val SHUFFLED_INDEX = "shuffled_index"
}
companion object {
private const val DB_NAME = "auxio_playback_state.db"
private const val DB_VERSION = 8
private const val DB_VERSION = 9
private const val TABLE_STATE = "playback_state"
private const val TABLE_QUEUE = "queue"
private const val TABLE_QUEUE_HEAP = "queue_heap"
private const val TABLE_QUEUE_MAPPINGS = "queue_mapping"
@Volatile private var INSTANCE: PlaybackStateDatabase? = null

View file

@ -155,8 +155,7 @@ class PlaybackStateManager private constructor() {
/**
* Start new playback.
* @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 parent The [MusicParent] to play from, or null if to play from the entire [Library].
* @param sort [Sort] to initially sort an ordered queue with.
* @param shuffled Whether to shuffle or not.
*/
@ -390,7 +389,7 @@ class PlaybackStateManager private constructor() {
/**
* Restore the previously saved state (if any) and apply it to the playback state.
* @param database The [PlaybackStateDatabase] to load from.
* @param force Whether to force a restore regardless of the current state.
* @param force Whether to do a restore regardless of any prior playback state.
* @return If the state was restored, false otherwise.
*/
suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean {
@ -399,49 +398,37 @@ class PlaybackStateManager private constructor() {
return false
}
// TODO: Re-implement with new queue
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
}
// 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
// }
// }
// 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)) {
parent = state.parent
queue.applySavedState(state.queueState)
repeatMode = state.repeatMode
notifyNewPlayback()
notifyRepeatModeChanged()
// Continuing playback after drastic state updates is a bad idea, so pause.
internalPlayer.loadSong(queue.currentSong, false)
internalPlayer.seekTo(state.positionMs)
isInitialized = true
true
} else {
false
}
}
}
/**
@ -451,26 +438,25 @@ class PlaybackStateManager private constructor() {
*/
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
logD("Saving state to DB")
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
// }
// Create the saved state from the current playback state.
val state =
synchronized(this) {
queue.toSavedState()?.let {
PlaybackStateDatabase.SavedState(
parent = parent,
queueState = it,
positionMs = playerState.calculateElapsedPositionMs(),
repeatMode = repeatMode)
}
}
return try {
withContext(Dispatchers.IO) { database.write(state) }
true
} catch (e: Exception) {
logE("Unable to save playback state.")
logE(e.stackTraceToString())
false
}
}
/**
@ -519,8 +505,9 @@ class PlaybackStateManager private constructor() {
}
// Sanitize the queue.
queue.applySavedState(
queue.toSavedState().remap { newLibrary.sanitize(unlikelyToBeNull(it)) })
queue.toSavedState()?.let { state ->
queue.applySavedState(state.remap { newLibrary.sanitize(unlikelyToBeNull(it)) })
}
notifyNewPlayback()

View file

@ -59,7 +59,13 @@ class Queue {
* 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] } }
fun resolve() =
if (currentSong != null) {
shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } }
} else {
// Queue doesn't exist, return saner data.
listOf()
}
/**
* Go to a particular index in the queue.
@ -253,12 +259,10 @@ class Queue {
* @return A new [SavedState] reflecting the exact state of the queue when called.
*/
fun toSavedState() =
currentSong?.let { song ->
SavedState(
heap.toList(),
orderedMapping.toList(),
shuffledMapping.toList(),
index,
currentSong?.uid)
heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid)
}
/**
* Update this instance from the given [SavedState].
@ -287,8 +291,8 @@ class Queue {
}
// Make sure we re-align the index to point to the previously playing song.
index = savedState.currentIndex
while (currentSong?.uid != savedState.currentSongUid && index > -1) {
index = savedState.index
while (currentSong?.uid != savedState.songUid && index > -1) {
index--
}
check()
@ -348,15 +352,15 @@ class Queue {
* other values.
* @param orderedMapping The mapping of the [heap] to an ordered queue.
* @param shuffledMapping The mapping of the [heap] to a shuffled queue.
* @param currentIndex The index of the currently playing [Song] at the time of serialization.
* @param currentSongUid The [Music.UID] of the [Song] that was originally at [currentIndex].
* @param index The index of the currently playing [Song] at the time of serialization.
* @param songUid The [Music.UID] of the [Song] that was originally at [index].
*/
class SavedState(
val heap: List<Song?>,
val orderedMapping: List<Int>,
val shuffledMapping: List<Int>,
val currentIndex: Int,
val currentSongUid: Music.UID?,
val index: Int,
val songUid: Music.UID,
) {
/**
* Remaps the [heap] of this instance based on the given mapping function and copies it into
@ -367,8 +371,7 @@ class Queue {
* @throws IllegalStateException If the invariant specified by [transform] is violated.
*/
inline fun remap(transform: (Song?) -> Song?) =
SavedState(
heap.map(transform), orderedMapping, shuffledMapping, currentIndex, currentSongUid)
SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid)
}
/**