From e451bc9859432d9de83c3fc487e1011cf212f121 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Mon, 2 May 2022 17:43:01 -0600 Subject: [PATCH] playback: rework state restore Rework state restore to be more coherent and in line with the new member layout of the general This primarily involves making the index the primary database attribute in favor of song, with the old song id field becoming a sanity check field. --- .../auxio/playback/PlaybackViewModel.kt | 8 +- .../playback/state/PlaybackStateDatabase.kt | 279 ++++++++++-------- .../playback/state/PlaybackStateManager.kt | 107 +++---- .../auxio/playback/system/PlaybackService.kt | 2 +- 4 files changed, 202 insertions(+), 194 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index c79854bd8..1b7fc5e8a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -276,7 +276,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { */ fun savePlaybackState(context: Context, onDone: () -> Unit) { viewModelScope.launch { - playbackManager.saveStateToDatabase(context) + playbackManager.saveState(context) onDone() } } @@ -293,12 +293,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { playWithUriInternal(intentUri, context) // Remove the uri after finishing the calls so that this does not fire again. mIntentUri = null - - // Were not going to be restoring playbackManager after this, so mark it as such. - playbackManager.markRestored() } else if (!playbackManager.isInitialized) { // Otherwise just restore - viewModelScope.launch { playbackManager.restoreFromDatabase(context) } + viewModelScope.launch { playbackManager.restoreState(context) } } } @@ -327,7 +324,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { } override fun onQueueChanged(index: Int, queue: List) { - mSong.value = playbackManager.song mNextUp.value = queue.slice(index.inc() until queue.size) } 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 f84929c77..d9fe4795e 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 @@ -23,6 +23,9 @@ import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import androidx.core.database.getLongOrNull import androidx.core.database.sqlite.transaction +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 @@ -34,8 +37,6 @@ import org.oxycblt.auxio.util.requireBackgroundThread * A SQLite database for managing the persistent playback state and queue. Yes. I know Room exists. * But that would needlessly bloat my app and has crippling bugs. * @author OxygenCobalt - * - * TODO: Rework to rely on queue indices more and only use specific items as fallbacks */ class PlaybackStateDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { @@ -77,10 +78,10 @@ class PlaybackStateDatabase(context: Context) : private fun constructStateTable(command: StringBuilder): StringBuilder { command .append("${StateColumns.COLUMN_ID} LONG PRIMARY KEY,") - .append("${StateColumns.COLUMN_SONG_HASH} LONG,") + .append("${StateColumns.COLUMN_SONG_ID} LONG,") .append("${StateColumns.COLUMN_POSITION} LONG NOT NULL,") - .append("${StateColumns.COLUMN_PARENT_HASH} LONG,") - .append("${StateColumns.COLUMN_QUEUE_INDEX} INTEGER NOT NULL,") + .append("${StateColumns.COLUMN_PARENT_ID} LONG,") + .append("${StateColumns.COLUMN_INDEX} INTEGER NOT NULL,") .append("${StateColumns.COLUMN_PLAYBACK_MODE} INTEGER NOT NULL,") .append("${StateColumns.COLUMN_IS_SHUFFLED} BOOLEAN NOT NULL,") .append("${StateColumns.COLUMN_REPEAT_MODE} INTEGER NOT NULL)") @@ -92,102 +93,73 @@ class PlaybackStateDatabase(context: Context) : private fun constructQueueTable(command: StringBuilder): StringBuilder { command .append("${QueueColumns.ID} LONG PRIMARY KEY,") - .append("${QueueColumns.SONG_HASH} INTEGER NOT NULL,") - .append("${QueueColumns.ALBUM_HASH} INTEGER NOT NULL)") + .append("${QueueColumns.SONG_ID} INTEGER NOT NULL,") + .append("${QueueColumns.ALBUM_ID} INTEGER NOT NULL)") return command } // --- INTERFACE FUNCTIONS --- - /** - * Read the stored [SavedState] from the database, if there is one. - * @param library Required to transform database songs/parents into actual instances - * @return The stored [SavedState], null if there isn't one. - */ - fun readState(library: MusicStore.Library): SavedState? { + fun read(library: MusicStore.Library): SavedState? { requireBackgroundThread() - var state: SavedState? = null + val rawState = readRawState() ?: return null + val queue = readQueue(library) - readableDatabase.queryAll(TABLE_NAME_STATE) { cursor -> - if (cursor.count == 0) return@queryAll + var actualIndex = rawState.index + while (queue.getOrNull(actualIndex)?.id != rawState.songId && actualIndex > -1) { + actualIndex-- + } + + val parent = + when (rawState.playbackMode) { + PlaybackMode.ALL_SONGS -> null + PlaybackMode.IN_ALBUM -> library.albums.find { it.id == rawState.parentId } + PlaybackMode.IN_ARTIST -> library.artists.find { it.id == rawState.parentId } + PlaybackMode.IN_GENRE -> library.genres.find { it.id == rawState.parentId } + } + + return SavedState( + index = actualIndex, + parent = parent, + queue = queue, + positionMs = rawState.positionMs, + repeatMode = rawState.repeatMode, + isShuffled = rawState.isShuffled, + ) + } + + private fun readRawState(): RawState? { + return readableDatabase.queryAll(TABLE_NAME_STATE) { cursor -> + if (cursor.count == 0) { + return@queryAll null + } + + val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_INDEX) - val songIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_SONG_HASH) val posIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_POSITION) - val parentIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PARENT_HASH) - val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_QUEUE_INDEX) - val modeIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PLAYBACK_MODE) - val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_IS_SHUFFLED) + val playbackModeIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PLAYBACK_MODE) val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_REPEAT_MODE) + val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_IS_SHUFFLED) + val songIdIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_SONG_ID) + val parentIdIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PARENT_ID) cursor.moveToFirst() - val song = - cursor.getLongOrNull(songIndex)?.let { id -> library.songs.find { it.id == id } } - - val mode = PlaybackMode.fromInt(cursor.getInt(modeIndex)) ?: PlaybackMode.ALL_SONGS - - val parent = - cursor.getLongOrNull(parentIndex)?.let { id -> - when (mode) { - PlaybackMode.IN_GENRE -> library.genres.find { it.id == id } - PlaybackMode.IN_ARTIST -> library.artists.find { it.id == id } - PlaybackMode.IN_ALBUM -> library.albums.find { it.id == id } - PlaybackMode.ALL_SONGS -> null - } - } - - state = - SavedState( - song = song, - positionMs = cursor.getLong(posIndex), - parent = parent, - queueIndex = cursor.getInt(indexIndex), - playbackMode = mode, - isShuffled = cursor.getInt(shuffleIndex) == 1, - repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex)) - ?: RepeatMode.NONE, - ) - - logD("Successfully read playback state: $state") + RawState( + index = cursor.getInt(indexIndex), + positionMs = cursor.getLong(posIndex), + repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex)) + ?: RepeatMode.NONE, + isShuffled = cursor.getInt(shuffleIndex) == 1, + songId = cursor.getLong(songIdIndex), + parentId = cursor.getLongOrNull(parentIdIndex), + playbackMode = PlaybackMode.fromInt(playbackModeIndex) ?: PlaybackMode.ALL_SONGS) } - - return state } - /** Clear the previously written [SavedState] and write a new one. */ - fun writeState(state: SavedState) { - requireBackgroundThread() - - writableDatabase.transaction { - delete(TABLE_NAME_STATE, null, null) - - this@PlaybackStateDatabase.logD("Wiped state db") - - val stateData = - ContentValues(10).apply { - put(StateColumns.COLUMN_ID, 0) - put(StateColumns.COLUMN_SONG_HASH, state.song?.id) - put(StateColumns.COLUMN_POSITION, state.positionMs) - put(StateColumns.COLUMN_PARENT_HASH, state.parent?.id) - put(StateColumns.COLUMN_QUEUE_INDEX, state.queueIndex) - put(StateColumns.COLUMN_PLAYBACK_MODE, state.playbackMode.intCode) - put(StateColumns.COLUMN_IS_SHUFFLED, state.isShuffled) - put(StateColumns.COLUMN_REPEAT_MODE, state.repeatMode.intCode) - } - - insert(TABLE_NAME_STATE, null, stateData) - } - - logD("Wrote state to database") - } - - /** - * Read a list of queue items from this database. - * @param musicStore Required to transform database songs into actual song instances - */ - fun readQueue(library: MusicStore.Library): MutableList { + private fun readQueue(library: MusicStore.Library): MutableList { requireBackgroundThread() val queue = mutableListOf() @@ -195,8 +167,8 @@ class PlaybackStateDatabase(context: Context) : readableDatabase.queryAll(TABLE_NAME_QUEUE) { cursor -> if (cursor.count == 0) return@queryAll - val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_HASH) - val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_HASH) + val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_ID) + val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_ID) while (cursor.moveToNext()) { library.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))?.let { @@ -211,67 +183,126 @@ class PlaybackStateDatabase(context: Context) : return queue } - /** Write a queue to the database. */ - fun writeQueue(queue: MutableList) { + /** Clear the previously written [SavedState] and write a new one. */ + fun write(state: SavedState) { requireBackgroundThread() + val song = state.queue.getOrNull(state.index) + + if (song != null) { + val rawState = + RawState( + index = state.index, + positionMs = state.positionMs, + repeatMode = state.repeatMode, + isShuffled = state.isShuffled, + songId = song.id, + parentId = state.parent?.id, + playbackMode = + when (state.parent) { + null -> PlaybackMode.ALL_SONGS + is Album -> PlaybackMode.IN_ALBUM + is Artist -> PlaybackMode.IN_ARTIST + is Genre -> PlaybackMode.IN_GENRE + }) + + writeRawState(rawState) + writeQueue(state.queue) + } else { + writeRawState(null) + writeQueue(null) + } + + logD("Wrote state to database") + } + + private fun writeRawState(rawState: RawState?) { + writableDatabase.transaction { + delete(TABLE_NAME_STATE, null, null) + + if (rawState != null) { + val stateData = + ContentValues(10).apply { + put(StateColumns.COLUMN_ID, 0) + put(StateColumns.COLUMN_SONG_ID, rawState.songId) + put(StateColumns.COLUMN_POSITION, rawState.positionMs) + put(StateColumns.COLUMN_PARENT_ID, rawState.parentId) + put(StateColumns.COLUMN_INDEX, rawState.index) + put(StateColumns.COLUMN_PLAYBACK_MODE, rawState.playbackMode.intCode) + put(StateColumns.COLUMN_IS_SHUFFLED, rawState.isShuffled) + put(StateColumns.COLUMN_REPEAT_MODE, rawState.repeatMode.intCode) + } + + insert(TABLE_NAME_STATE, null, stateData) + } + } + } + + /** Write a queue to the database. */ + private fun writeQueue(queue: List?) { val database = writableDatabase database.transaction { delete(TABLE_NAME_QUEUE, null, null) } logD("Wiped queue db") - writeQueueBatch(queue, queue.size) - } + if (queue != null) { + val idStart = queue.size + logD("Beginning queue write [start: $idStart]") + var position = 0 - private fun writeQueueBatch(queue: List, idStart: Int) { - logD("Beginning queue write [start: $idStart]") + while (position < queue.size) { + var i = position - val database = writableDatabase - var position = 0 + database.transaction { + while (i < queue.size) { + val song = queue[i] + i++ - while (position < queue.size) { - var i = position + val itemData = + ContentValues(4).apply { + put(QueueColumns.ID, idStart + i) + put(QueueColumns.SONG_ID, song.id) + put(QueueColumns.ALBUM_ID, song.album.id) + } - database.transaction { - while (i < queue.size) { - val song = queue[i] - i++ - - val itemData = - ContentValues(4).apply { - put(QueueColumns.ID, idStart + i) - put(QueueColumns.SONG_HASH, song.id) - put(QueueColumns.ALBUM_HASH, song.album.id) - } - - insert(TABLE_NAME_QUEUE, null, itemData) + insert(TABLE_NAME_QUEUE, null, itemData) + } } + + // Update the position at the end, if an insert failed at any point, then + // the next iteration should skip it. + position = i + + logD("Wrote batch of songs. Position is now at $position") } - - // Update the position at the end, if an insert failed at any point, then - // the next iteration should skip it. - position = i - - logD("Wrote batch of songs. Position is now at $position") } } data class SavedState( - val song: Song?, - val positionMs: Long, + val index: Int, + val queue: List, val parent: MusicParent?, - val queueIndex: Int, - val playbackMode: PlaybackMode, - val isShuffled: Boolean, + val positionMs: Long, val repeatMode: RepeatMode, + val isShuffled: Boolean, + ) + + private data class RawState( + val index: Int, + val positionMs: Long, + val repeatMode: RepeatMode, + val isShuffled: Boolean, + val songId: Long, + val parentId: Long?, + val playbackMode: PlaybackMode ) private object StateColumns { const val COLUMN_ID = "id" - const val COLUMN_SONG_HASH = "song" + const val COLUMN_SONG_ID = "song" const val COLUMN_POSITION = "position" - const val COLUMN_PARENT_HASH = "parent" - const val COLUMN_QUEUE_INDEX = "queue_index" + const val COLUMN_PARENT_ID = "parent" + const val COLUMN_INDEX = "queue_index" const val COLUMN_PLAYBACK_MODE = "playback_mode" const val COLUMN_IS_SHUFFLED = "is_shuffling" const val COLUMN_REPEAT_MODE = "loop_mode" @@ -279,8 +310,8 @@ class PlaybackStateDatabase(context: Context) : private object QueueColumns { const val ID = "id" - const val SONG_HASH = "song" - const val ALBUM_HASH = "album" + const val SONG_ID = "song" + const val ALBUM_ID = "album" } companion object { 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 0119b45a4..80e650c36 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 @@ -317,20 +317,50 @@ class PlaybackStateManager private constructor() { } } - // TODO: Rework these methods eventually + // --- PERSISTENCE FUNCTIONS --- + + /** + * Restore the state from the database + * @param context [Context] required. + */ + suspend fun restoreState(context: Context) { + val library = musicStore.library ?: return + val start: Long + val database = PlaybackStateDatabase.getInstance(context) + val state: PlaybackStateDatabase.SavedState? + + logD("Getting state from DB") + + withContext(Dispatchers.IO) { + start = System.currentTimeMillis() + state = database.read(library) + } + + logD("State read completed successfully in ${System.currentTimeMillis() - start}ms") + + // Get off the IO coroutine since it will cause LiveData updates to throw an exception + + if (state != null) { + index = state.index + parent = state.parent + mutableQueue = state.queue.toMutableList() + repeatMode = state.repeatMode + isShuffled = state.isShuffled + + notifyNewPlayback() + seekTo(state.positionMs) + notifyRepeatModeChanged() + notifyShuffledChanged() + } - /** Mark this instance as restored. */ - fun markRestored() { isInitialized = true } - // --- PERSISTENCE FUNCTIONS --- - /** * Save the current state to the database. * @param context [Context] required */ - suspend fun saveStateToDatabase(context: Context) { + suspend fun saveState(context: Context) { logD("Saving state to DB") // Pack the entire state and save it to the database. @@ -338,69 +368,20 @@ class PlaybackStateManager private constructor() { val start = System.currentTimeMillis() val database = PlaybackStateDatabase.getInstance(context) - val playbackMode = - when (parent) { - is Album -> PlaybackMode.IN_ALBUM - is Artist -> PlaybackMode.IN_ARTIST - is Genre -> PlaybackMode.IN_GENRE - null -> PlaybackMode.ALL_SONGS - } - - database.writeState( + database.write( PlaybackStateDatabase.SavedState( - song, - positionMs, - parent, - index, - playbackMode, - isShuffled, - repeatMode, - )) - - database.writeQueue(mutableQueue) + index = index, + parent = parent, + queue = mutableQueue, + positionMs = positionMs, + isShuffled = isShuffled, + repeatMode = repeatMode)) this@PlaybackStateManager.logD( "State save completed successfully in ${System.currentTimeMillis() - start}ms") } - } - /** - * Restore the state from the database - * @param context [Context] required. - */ - suspend fun restoreFromDatabase(context: Context) { - logD("Getting state from DB") - - val library = musicStore.library ?: return - val start: Long - val playbackState: PlaybackStateDatabase.SavedState? - val queue: MutableList - - withContext(Dispatchers.IO) { - start = System.currentTimeMillis() - val database = PlaybackStateDatabase.getInstance(context) - playbackState = database.readState(library) - queue = database.readQueue(library) - } - - // Get off the IO coroutine since it will cause LiveData updates to throw an exception - - if (playbackState != null) { - parent = playbackState.parent - mutableQueue = queue - index = playbackState.queueIndex - repeatMode = playbackState.repeatMode - isShuffled = playbackState.isShuffled - - notifyNewPlayback() - seekTo(playbackState.positionMs) - notifyRepeatModeChanged() - notifyShuffledChanged() - } - - logD("State load completed successfully in ${System.currentTimeMillis() - start}ms") - - markRestored() + isInitialized = true } // --- CALLBACKS --- diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index f6f90ea74..a8e836b28 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -387,7 +387,7 @@ class PlaybackService : private fun stopAndSave() { stopForeground(true) isForeground = false - saveScope.launch { playbackManager.saveStateToDatabase(this@PlaybackService) } + saveScope.launch { playbackManager.saveState(this@PlaybackService) } } /** A [BroadcastReceiver] for receiving general playback events from the system. */