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:
parent
1e7a439c31
commit
e451bc9859
4 changed files with 202 additions and 194 deletions
|
@ -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<Song>) {
|
||||
mSong.value = playbackManager.song
|
||||
mNextUp.value = queue.slice(index.inc() until queue.size)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
RawState(
|
||||
index = cursor.getInt(indexIndex),
|
||||
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")
|
||||
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<Song> {
|
||||
private fun readQueue(library: MusicStore.Library): MutableList<Song> {
|
||||
requireBackgroundThread()
|
||||
|
||||
val queue = mutableListOf<Song>()
|
||||
|
@ -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,22 +183,71 @@ class PlaybackStateDatabase(context: Context) :
|
|||
return queue
|
||||
}
|
||||
|
||||
/** Write a queue to the database. */
|
||||
fun writeQueue(queue: MutableList<Song>) {
|
||||
/** 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<Song>?) {
|
||||
val database = writableDatabase
|
||||
database.transaction { delete(TABLE_NAME_QUEUE, null, null) }
|
||||
|
||||
logD("Wiped queue db")
|
||||
|
||||
writeQueueBatch(queue, queue.size)
|
||||
}
|
||||
|
||||
private fun writeQueueBatch(queue: List<Song>, idStart: Int) {
|
||||
if (queue != null) {
|
||||
val idStart = queue.size
|
||||
logD("Beginning queue write [start: $idStart]")
|
||||
|
||||
val database = writableDatabase
|
||||
var position = 0
|
||||
|
||||
while (position < queue.size) {
|
||||
|
@ -240,8 +261,8 @@ class PlaybackStateDatabase(context: Context) :
|
|||
val itemData =
|
||||
ContentValues(4).apply {
|
||||
put(QueueColumns.ID, idStart + i)
|
||||
put(QueueColumns.SONG_HASH, song.id)
|
||||
put(QueueColumns.ALBUM_HASH, song.album.id)
|
||||
put(QueueColumns.SONG_ID, song.id)
|
||||
put(QueueColumns.ALBUM_ID, song.album.id)
|
||||
}
|
||||
|
||||
insert(TABLE_NAME_QUEUE, null, itemData)
|
||||
|
@ -255,23 +276,33 @@ class PlaybackStateDatabase(context: Context) :
|
|||
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<Song>,
|
||||
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 {
|
||||
|
|
|
@ -317,20 +317,50 @@ class PlaybackStateManager private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: Rework these methods eventually
|
||||
// --- PERSISTENCE FUNCTIONS ---
|
||||
|
||||
/** Mark this instance as restored. */
|
||||
fun markRestored() {
|
||||
isInitialized = true
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
// --- PERSISTENCE FUNCTIONS ---
|
||||
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()
|
||||
}
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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()
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
// --- CALLBACKS ---
|
||||
|
|
|
@ -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. */
|
||||
|
|
Loading…
Reference in a new issue