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.
This commit is contained in:
OxygenCobalt 2022-05-02 17:43:01 -06:00
parent 1e7a439c31
commit e451bc9859
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 202 additions and 194 deletions

View file

@ -276,7 +276,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/ */
fun savePlaybackState(context: Context, onDone: () -> Unit) { fun savePlaybackState(context: Context, onDone: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
playbackManager.saveStateToDatabase(context) playbackManager.saveState(context)
onDone() onDone()
} }
} }
@ -293,12 +293,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playWithUriInternal(intentUri, context) playWithUriInternal(intentUri, context)
// Remove the uri after finishing the calls so that this does not fire again. // Remove the uri after finishing the calls so that this does not fire again.
mIntentUri = null mIntentUri = null
// Were not going to be restoring playbackManager after this, so mark it as such.
playbackManager.markRestored()
} else if (!playbackManager.isInitialized) { } else if (!playbackManager.isInitialized) {
// Otherwise just restore // 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<Song>) { override fun onQueueChanged(index: Int, queue: List<Song>) {
mSong.value = playbackManager.song
mNextUp.value = queue.slice(index.inc() until queue.size) mNextUp.value = queue.slice(index.inc() until queue.size)
} }

View file

@ -23,6 +23,9 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.getLongOrNull import androidx.core.database.getLongOrNull
import androidx.core.database.sqlite.transaction 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.MusicParent
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song 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. * 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. * But that would needlessly bloat my app and has crippling bugs.
* @author OxygenCobalt * @author OxygenCobalt
*
* TODO: Rework to rely on queue indices more and only use specific items as fallbacks
*/ */
class PlaybackStateDatabase(context: Context) : class PlaybackStateDatabase(context: Context) :
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
@ -77,10 +78,10 @@ class PlaybackStateDatabase(context: Context) :
private fun constructStateTable(command: StringBuilder): StringBuilder { private fun constructStateTable(command: StringBuilder): StringBuilder {
command command
.append("${StateColumns.COLUMN_ID} LONG PRIMARY KEY,") .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_POSITION} LONG NOT NULL,")
.append("${StateColumns.COLUMN_PARENT_HASH} LONG,") .append("${StateColumns.COLUMN_PARENT_ID} LONG,")
.append("${StateColumns.COLUMN_QUEUE_INDEX} INTEGER NOT NULL,") .append("${StateColumns.COLUMN_INDEX} INTEGER NOT NULL,")
.append("${StateColumns.COLUMN_PLAYBACK_MODE} INTEGER NOT NULL,") .append("${StateColumns.COLUMN_PLAYBACK_MODE} INTEGER NOT NULL,")
.append("${StateColumns.COLUMN_IS_SHUFFLED} BOOLEAN NOT NULL,") .append("${StateColumns.COLUMN_IS_SHUFFLED} BOOLEAN NOT NULL,")
.append("${StateColumns.COLUMN_REPEAT_MODE} INTEGER NOT NULL)") .append("${StateColumns.COLUMN_REPEAT_MODE} INTEGER NOT NULL)")
@ -92,102 +93,73 @@ class PlaybackStateDatabase(context: Context) :
private fun constructQueueTable(command: StringBuilder): StringBuilder { private fun constructQueueTable(command: StringBuilder): StringBuilder {
command command
.append("${QueueColumns.ID} LONG PRIMARY KEY,") .append("${QueueColumns.ID} LONG PRIMARY KEY,")
.append("${QueueColumns.SONG_HASH} INTEGER NOT NULL,") .append("${QueueColumns.SONG_ID} INTEGER NOT NULL,")
.append("${QueueColumns.ALBUM_HASH} INTEGER NOT NULL)") .append("${QueueColumns.ALBUM_ID} INTEGER NOT NULL)")
return command return command
} }
// --- INTERFACE FUNCTIONS --- // --- INTERFACE FUNCTIONS ---
/** fun read(library: MusicStore.Library): SavedState? {
* 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? {
requireBackgroundThread() requireBackgroundThread()
var state: SavedState? = null val rawState = readRawState() ?: return null
val queue = readQueue(library)
readableDatabase.queryAll(TABLE_NAME_STATE) { cursor -> var actualIndex = rawState.index
if (cursor.count == 0) return@queryAll 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 posIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_POSITION)
val parentIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PARENT_HASH) val playbackModeIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PLAYBACK_MODE)
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 repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_REPEAT_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() cursor.moveToFirst()
val song = RawState(
cursor.getLongOrNull(songIndex)?.let { id -> library.songs.find { it.id == id } } index = cursor.getInt(indexIndex),
positionMs = cursor.getLong(posIndex),
val mode = PlaybackMode.fromInt(cursor.getInt(modeIndex)) ?: PlaybackMode.ALL_SONGS repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex))
?: RepeatMode.NONE,
val parent = isShuffled = cursor.getInt(shuffleIndex) == 1,
cursor.getLongOrNull(parentIndex)?.let { id -> songId = cursor.getLong(songIdIndex),
when (mode) { parentId = cursor.getLongOrNull(parentIdIndex),
PlaybackMode.IN_GENRE -> library.genres.find { it.id == id } playbackMode = PlaybackMode.fromInt(playbackModeIndex) ?: PlaybackMode.ALL_SONGS)
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")
} }
return state
} }
/** Clear the previously written [SavedState] and write a new one. */ private fun readQueue(library: MusicStore.Library): MutableList<Song> {
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<Song> {
requireBackgroundThread() requireBackgroundThread()
val queue = mutableListOf<Song>() val queue = mutableListOf<Song>()
@ -195,8 +167,8 @@ class PlaybackStateDatabase(context: Context) :
readableDatabase.queryAll(TABLE_NAME_QUEUE) { cursor -> readableDatabase.queryAll(TABLE_NAME_QUEUE) { cursor ->
if (cursor.count == 0) return@queryAll if (cursor.count == 0) return@queryAll
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_HASH) val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_ID)
val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_HASH) val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_ID)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
library.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))?.let { library.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))?.let {
@ -211,67 +183,126 @@ class PlaybackStateDatabase(context: Context) :
return queue return queue
} }
/** Write a queue to the database. */ /** Clear the previously written [SavedState] and write a new one. */
fun writeQueue(queue: MutableList<Song>) { fun write(state: SavedState) {
requireBackgroundThread() 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<Song>?) {
val database = writableDatabase val database = writableDatabase
database.transaction { delete(TABLE_NAME_QUEUE, null, null) } database.transaction { delete(TABLE_NAME_QUEUE, null, null) }
logD("Wiped queue db") 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<Song>, idStart: Int) { while (position < queue.size) {
logD("Beginning queue write [start: $idStart]") var i = position
val database = writableDatabase database.transaction {
var position = 0 while (i < queue.size) {
val song = queue[i]
i++
while (position < queue.size) { val itemData =
var i = position ContentValues(4).apply {
put(QueueColumns.ID, idStart + i)
put(QueueColumns.SONG_ID, song.id)
put(QueueColumns.ALBUM_ID, song.album.id)
}
database.transaction { insert(TABLE_NAME_QUEUE, null, itemData)
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)
} }
// 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( data class SavedState(
val song: Song?, val index: Int,
val positionMs: Long, val queue: List<Song>,
val parent: MusicParent?, val parent: MusicParent?,
val queueIndex: Int, val positionMs: Long,
val playbackMode: PlaybackMode,
val isShuffled: Boolean,
val repeatMode: RepeatMode, 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 { private object StateColumns {
const val COLUMN_ID = "id" 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_POSITION = "position"
const val COLUMN_PARENT_HASH = "parent" const val COLUMN_PARENT_ID = "parent"
const val COLUMN_QUEUE_INDEX = "queue_index" const val COLUMN_INDEX = "queue_index"
const val COLUMN_PLAYBACK_MODE = "playback_mode" const val COLUMN_PLAYBACK_MODE = "playback_mode"
const val COLUMN_IS_SHUFFLED = "is_shuffling" const val COLUMN_IS_SHUFFLED = "is_shuffling"
const val COLUMN_REPEAT_MODE = "loop_mode" const val COLUMN_REPEAT_MODE = "loop_mode"
@ -279,8 +310,8 @@ class PlaybackStateDatabase(context: Context) :
private object QueueColumns { private object QueueColumns {
const val ID = "id" const val ID = "id"
const val SONG_HASH = "song" const val SONG_ID = "song"
const val ALBUM_HASH = "album" const val ALBUM_ID = "album"
} }
companion object { companion object {

View file

@ -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 isInitialized = true
} }
// --- PERSISTENCE FUNCTIONS ---
/** /**
* Save the current state to the database. * Save the current state to the database.
* @param context [Context] required * @param context [Context] required
*/ */
suspend fun saveStateToDatabase(context: Context) { suspend fun saveState(context: Context) {
logD("Saving state to DB") logD("Saving state to DB")
// Pack the entire state and save it to the database. // Pack the entire state and save it to the database.
@ -338,69 +368,20 @@ class PlaybackStateManager private constructor() {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val database = PlaybackStateDatabase.getInstance(context) val database = PlaybackStateDatabase.getInstance(context)
val playbackMode = database.write(
when (parent) {
is Album -> PlaybackMode.IN_ALBUM
is Artist -> PlaybackMode.IN_ARTIST
is Genre -> PlaybackMode.IN_GENRE
null -> PlaybackMode.ALL_SONGS
}
database.writeState(
PlaybackStateDatabase.SavedState( PlaybackStateDatabase.SavedState(
song, index = index,
positionMs, parent = parent,
parent, queue = mutableQueue,
index, positionMs = positionMs,
playbackMode, isShuffled = isShuffled,
isShuffled, repeatMode = repeatMode))
repeatMode,
))
database.writeQueue(mutableQueue)
this@PlaybackStateManager.logD( this@PlaybackStateManager.logD(
"State save completed successfully in ${System.currentTimeMillis() - start}ms") "State save completed successfully in ${System.currentTimeMillis() - start}ms")
} }
}
/** isInitialized = true
* 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<Song>
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()
} }
// --- CALLBACKS --- // --- CALLBACKS ---

View file

@ -387,7 +387,7 @@ class PlaybackService :
private fun stopAndSave() { private fun stopAndSave() {
stopForeground(true) stopForeground(true)
isForeground = false isForeground = false
saveScope.launch { playbackManager.saveStateToDatabase(this@PlaybackService) } saveScope.launch { playbackManager.saveState(this@PlaybackService) }
} }
/** A [BroadcastReceiver] for receiving general playback events from the system. */ /** A [BroadcastReceiver] for receiving general playback events from the system. */