From 692839e8fef580df2dabeb377e484f35b422d584 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 9 Jan 2023 13:53:37 -0700 Subject: [PATCH] playback: re-add state persistence Re-add state persistence with support for the new queue. This should finally finish the new queue system. --- .../auxio/playback/queue/QueueFragment.kt | 1 + .../playback/state/PlaybackStateDatabase.kt | 225 +++++++++++------- .../playback/state/PlaybackStateManager.kt | 121 +++++----- .../org/oxycblt/auxio/playback/state/Queue.kt | 33 +-- 4 files changed, 210 insertions(+), 170 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 50326e156..396e1eb92 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -77,6 +77,7 @@ class QueueFragment : ViewBindingFragment(), EditableListL override fun onDestroyBinding(binding: FragmentQueueBinding) { super.onDestroyBinding(binding) + touchHelper = null binding.queueRecycler.adapter = null } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt index 8aea48e50..1fbbde002 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt @@ -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(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 { - val queue = mutableListOf() - readableDatabase.queryAll(TABLE_QUEUE) { cursor -> - val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID) + private fun readRawQueueState(library: Library): RawQueueState { + val heap = mutableListOf() + 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(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() + val shuffledMapping = mutableListOf() + 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?) { - 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, 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, + /** @see Queue.SavedState.orderedMapping */ + val orderedMapping: List, + /** @see Queue.SavedState.shuffledMapping */ + val shuffledMapping: List + ) + /** 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 diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index efa375415..4f0bcf641 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -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 - 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 + } - // 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() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 36655d543..7759f0b3d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -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() = - SavedState( - heap.toList(), - orderedMapping.toList(), - shuffledMapping.toList(), - index, - currentSong?.uid) + currentSong?.let { song -> + SavedState( + 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, val orderedMapping: List, val shuffledMapping: List, - 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) } /**