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. */