Implement persistence

Implement the ability for [most of] the playback state to persist even after the app's process has been killed.
This commit is contained in:
OxygenCobalt 2020-11-14 16:53:35 -07:00
parent ee95bc1a9e
commit 6d809f4303
14 changed files with 355 additions and 7 deletions

View file

@ -72,6 +72,13 @@ dependencies {
// Media
implementation 'androidx.media:media:1.2.0'
// Database
def room_version = '2.2.5'
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation "androidx.room:room-ktx:$room_version"
// --- THIRD PARTY ---
// Image loading

View file

@ -0,0 +1,41 @@
package org.oxycblt.auxio.database
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "playback_state_table")
data class PlaybackState(
@PrimaryKey(autoGenerate = true)
var id: Long = 0L,
@ColumnInfo(name = "song_id")
val songId: Long = -1L,
@ColumnInfo(name = "position")
val position: Long,
@ColumnInfo(name = "parent_id")
val parentId: Long = -1L,
@ColumnInfo(name = "user_queue")
val userQueueIds: String,
@ColumnInfo(name = "index")
val index: Int,
@ColumnInfo(name = "mode")
val mode: Int,
@ColumnInfo(name = "is_shuffling")
val isShuffling: Boolean,
@ColumnInfo(name = "shuffle_seed")
val shuffleSeed: Long,
@ColumnInfo(name = "loop_mode")
val loopMode: Int,
@ColumnInfo(name = "in_user_queue")
val inUserQueue: Boolean
)

View file

@ -0,0 +1,21 @@
package org.oxycblt.auxio.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
@Dao
interface PlaybackStateDAO {
@Insert
fun insert(playbackState: PlaybackState)
@Update
fun update(playbackState: PlaybackState)
@Query("SELECT * FROM playback_state_table")
fun getAll(): List<PlaybackState>
@Query("DELETE FROM playback_state_table")
fun clear()
}

View file

@ -0,0 +1,37 @@
package org.oxycblt.auxio.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [PlaybackState::class], version = 1, exportSchema = false)
abstract class PlaybackStateDatabase : RoomDatabase() {
abstract val playbackStateDAO: PlaybackStateDAO
companion object {
@Volatile
private var INSTANCE: PlaybackStateDatabase? = null
/**
* Get/Instantiate the single instance of [PlaybackStateDatabase].
*/
fun getInstance(context: Context): PlaybackStateDatabase {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
}
synchronized(this) {
val newInstance = Room.databaseBuilder(
context.applicationContext,
PlaybackStateDatabase::class.java,
"playback_state_database"
).fallbackToDestructiveMigration().build()
INSTANCE = newInstance
return newInstance
}
}
}
}

View file

@ -0,0 +1,30 @@
package org.oxycblt.auxio.database
import org.json.JSONArray
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
object QueueConverter {
fun fromString(arrayString: String): MutableList<Song> {
val jsonArray = JSONArray(arrayString)
val queue = mutableListOf<Song>()
val musicStore = MusicStore.getInstance()
for (i in 0 until jsonArray.length()) {
val id = jsonArray.getLong(i)
musicStore.songs.find { it.id == id }?.let {
queue.add(it)
}
}
return queue
}
fun fromQueue(queueIds: List<Long>): String {
val jsonArray = JSONArray()
queueIds.forEach {
jsonArray.put(it)
}
return jsonArray.toString(0)
}
}

View file

@ -24,6 +24,14 @@ class MusicStore private constructor() {
private var mSongs = listOf<Song>()
val songs: List<Song> get() = mSongs
val parents: MutableList<BaseModel> by lazy {
val parents = mutableListOf<BaseModel>()
parents.addAll(mGenres)
parents.addAll(mArtists)
parents.addAll(mAlbums)
parents
}
var loaded = false
private set

View file

@ -19,7 +19,7 @@ import org.oxycblt.auxio.music.MusicStore
* A [Fragment] that displays the currently played song at a glance, with some basic controls.
* Extends into [PlaybackFragment] when clicked on.
*
* Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
* Instantiation is done by FragmentContainerView, **do not instantiate this fragment manually.**
* @author OxygenCobalt
*/
class CompactPlaybackFragment : Fragment() {

View file

@ -32,6 +32,12 @@ object NotificationUtils {
const val ACTION_EXIT = "ACTION_AUXIO_EXIT"
}
/**
* Create the standard media notification used by Auxio.
* @param context [Context] required to create the notification
* @param mediaSession [MediaSessionCompat] required for the [MediaStyle] notification
* @author OxygenCobalt
*/
fun NotificationManager.createMediaNotification(
context: Context,
mediaSession: MediaSessionCompat

View file

@ -157,9 +157,13 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
// Release everything that could cause a memory leak if left around
player.release()
mediaSession.release()
serviceJob.cancel()
playbackManager.removeCallback(this)
serviceScope.launch {
playbackManager.saveStateToDatabase(this@PlaybackService)
serviceJob.cancel()
}
Log.d(this::class.simpleName, "Service destroyed.")
}
@ -213,7 +217,10 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
player.setMediaItem(item)
player.prepare()
if (playbackManager.isPlaying) {
player.play()
}
uploadMetadataToSession(it)
notification.setMetadata(playbackManager.song!!, this) {
@ -272,6 +279,14 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
player.seekTo(position)
}
override fun onNeedContextToRestoreState() {
Log.d(this::class.simpleName, "Giving context to PlaybackStateManager")
serviceScope.launch {
playbackManager.getStateFromDatabase(this@PlaybackService)
}
}
// --- OTHER FUNCTIONS ---
private fun restorePlayer() {
@ -382,7 +397,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
return false
}
// BroadcastReceiver for receiving system events [E.G Headphones connected/disconnected]
/**
* A [BroadcastReceiver] for receiving system events from the media notification or the headset.
*/
private inner class SystemEventReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import org.oxycblt.auxio.database.QueueConverter
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
@ -83,6 +84,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// state.
if (playbackManager.song != null) {
restorePlaybackState()
} else {
playbackManager.needContextToRestoreState()
}
}
@ -156,6 +159,19 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playbackManager.prev()
}
fun goto(value: Boolean) {
Log.d(
this::class.simpleName,
QueueConverter.fromQueue(
mutableListOf<Long>().apply {
forEach {
this.add(it)
}
}
)
)
}
// Remove a queue OR user queue item, given a QueueAdapter index.
fun removeQueueItem(adapterIndex: Int, queueAdapter: QueueAdapter) {
var index = adapterIndex.dec()

View file

@ -16,6 +16,12 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.applyDivider
/**
* A [Fragment] that contains both the user queue and the next queue, with the ability to
* edit them as well.
*
* Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
*/
class QueueFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()

View file

@ -3,6 +3,14 @@ package org.oxycblt.auxio.playback.state
enum class LoopMode {
NONE, ONCE, INFINITE;
fun toConstant(): Int {
return when (this) {
NONE -> CONSTANT_NONE
ONCE -> CONSTANT_ONCE
INFINITE -> CONSTANT_INFINITE
}
}
fun increment(): LoopMode {
return when (this) {
NONE -> ONCE
@ -10,4 +18,20 @@ enum class LoopMode {
INFINITE -> NONE
}
}
companion object {
const val CONSTANT_NONE = 0xA050
const val CONSTANT_ONCE = 0xA051
const val CONSTANT_INFINITE = 0xA052
fun fromConstant(constant: Int): LoopMode? {
return when (constant) {
CONSTANT_NONE -> NONE
CONSTANT_ONCE -> ONCE
CONSTANT_INFINITE -> INFINITE
else -> null
}
}
}
}

View file

@ -6,4 +6,31 @@ package org.oxycblt.auxio.playback.state
// IN_ALBUM -> Play from the songs of the album
enum class PlaybackMode {
IN_ARTIST, IN_GENRE, IN_ALBUM, ALL_SONGS;
fun toConstant(): Int {
return when (this) {
IN_ARTIST -> CONSTANT_IN_ARTIST
IN_GENRE -> CONSTANT_IN_GENRE
IN_ALBUM -> CONSTANT_IN_ALBUM
ALL_SONGS -> CONSTANT_ALL_SONGS
}
}
companion object {
const val CONSTANT_IN_ARTIST = 0xA040
const val CONSTANT_IN_GENRE = 0xA041
const val CONSTANT_IN_ALBUM = 0x4042
const val CONSTANT_ALL_SONGS = 0x4043
fun fromConstant(constant: Int): PlaybackMode? {
return when (constant) {
CONSTANT_IN_ARTIST -> IN_ARTIST
CONSTANT_IN_ALBUM -> IN_ALBUM
CONSTANT_IN_GENRE -> IN_GENRE
CONSTANT_ALL_SONGS -> ALL_SONGS
else -> null
}
}
}
}

View file

@ -1,6 +1,12 @@
package org.oxycblt.auxio.playback.state
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.database.PlaybackState
import org.oxycblt.auxio.database.PlaybackStateDatabase
import org.oxycblt.auxio.database.QueueConverter
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
@ -348,11 +354,10 @@ class PlaybackStateManager private constructor() {
// can be restored when its started again.
val newSeed = Random.Default.nextLong()
Log.d(this::class.simpleName, "Shuffling queue with a seed of $newSeed.")
mShuffleSeed = newSeed
mQueue.shuffle(Random(newSeed))
Log.d(this::class.simpleName, "Shuffling queue with a seed of $mShuffleSeed.")
mQueue.shuffle(Random(mShuffleSeed))
mIndex = 0
// If specified, make the current song the first member of the queue.
@ -380,6 +385,8 @@ class PlaybackStateManager private constructor() {
}
mIndex = mQueue.indexOf(mSong)
forceQueueUpdate()
}
// --- STATE FUNCTIONS ---
@ -441,6 +448,106 @@ class PlaybackStateManager private constructor() {
return final
}
// --- PERSISTENCE FUNCTIONS ---
// TODO: Persist queue edits?
// FIXME: Calling genShuffle without knowing the original queue edit from keepSong will cause issues.
fun needContextToRestoreState() {
callbacks.forEach { it.onNeedContextToRestoreState() }
}
suspend fun saveStateToDatabase(context: Context) {
Log.d(this::class.simpleName, "Saving state to DB.")
withContext(Dispatchers.IO) {
val playbackState = packToPlaybackState()
val database = PlaybackStateDatabase.getInstance(context)
database.playbackStateDAO.clear()
database.playbackStateDAO.insert(playbackState)
}
}
suspend fun getStateFromDatabase(context: Context) {
Log.d(this::class.simpleName, "Getting state from DB.")
withContext(Dispatchers.IO) {
val database = PlaybackStateDatabase.getInstance(context)
val states = database.playbackStateDAO.getAll()
if (states.isEmpty()) {
Log.d(this::class.simpleName, "Nothing here. Not restoring.")
return@withContext
}
val state = states[0]
Log.d(this::class.simpleName, "Old state found, $state")
database.playbackStateDAO.clear()
withContext(Dispatchers.Main) {
val musicStore = MusicStore.getInstance()
mSong = musicStore.songs.find { it.id == state.songId }
mPosition = state.position
mParent = musicStore.parents.find { it.id == state.parentId }
mUserQueue = QueueConverter.fromString(state.userQueueIds)
mMode = PlaybackMode.fromConstant(state.mode) ?: PlaybackMode.ALL_SONGS
mLoopMode = LoopMode.fromConstant(state.loopMode) ?: LoopMode.NONE
mIsShuffling = state.isShuffling
mShuffleSeed = state.shuffleSeed
mIsInUserQueue = state.inUserQueue
mQueue = when (mMode) {
PlaybackMode.IN_ARTIST -> orderSongsInArtist(mParent as Artist)
PlaybackMode.IN_ALBUM -> orderSongsInAlbum(mParent as Album)
PlaybackMode.IN_GENRE -> orderSongsInGenre(mParent as Genre)
PlaybackMode.ALL_SONGS -> musicStore.songs.toMutableList()
}
if (mIsShuffling) {
Log.d(this::class.simpleName, "You stupid fucking retard. JUST FUNCTION.")
mQueue.shuffle(Random(mShuffleSeed))
}
mIndex = state.index
}
}
// Update PlaybackService outside of the main thread since its special for some reason
callbacks.forEach {
it.onSeekConfirm(mPosition)
}
}
private fun packToPlaybackState(): PlaybackState {
val songId = mSong?.id ?: -1L
val parentId = mParent?.id ?: -1L
val userQueueString = QueueConverter.fromQueue(
mutableListOf<Long>().apply {
mUserQueue.forEach {
this.add(it.id)
}
}
)
val intMode = mMode.toConstant()
val intLoopMode = mLoopMode.toConstant()
return PlaybackState(
songId = songId,
position = mPosition,
parentId = parentId,
userQueueIds = userQueueString,
index = mIndex,
mode = intMode,
isShuffling = mIsShuffling,
shuffleSeed = mShuffleSeed,
loopMode = intLoopMode,
inUserQueue = mIsInUserQueue
)
}
/**
* The interface for receiving updates from [PlaybackStateManager].
* Add the callback to [PlaybackStateManager] using [addCallback],
@ -458,6 +565,7 @@ class PlaybackStateManager private constructor() {
fun onShuffleUpdate(isShuffling: Boolean) {}
fun onLoopUpdate(mode: LoopMode) {}
fun onSeekConfirm(position: Long) {}
fun onNeedContextToRestoreState() {}
}
companion object {