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 // Media
implementation 'androidx.media:media:1.2.0' 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 --- // --- THIRD PARTY ---
// Image loading // 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>() private var mSongs = listOf<Song>()
val songs: List<Song> get() = mSongs 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 var loaded = false
private set 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. * A [Fragment] that displays the currently played song at a glance, with some basic controls.
* Extends into [PlaybackFragment] when clicked on. * 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 * @author OxygenCobalt
*/ */
class CompactPlaybackFragment : Fragment() { class CompactPlaybackFragment : Fragment() {

View file

@ -32,6 +32,12 @@ object NotificationUtils {
const val ACTION_EXIT = "ACTION_AUXIO_EXIT" 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( fun NotificationManager.createMediaNotification(
context: Context, context: Context,
mediaSession: MediaSessionCompat 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 // Release everything that could cause a memory leak if left around
player.release() player.release()
mediaSession.release() mediaSession.release()
serviceJob.cancel()
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
serviceScope.launch {
playbackManager.saveStateToDatabase(this@PlaybackService)
serviceJob.cancel()
}
Log.d(this::class.simpleName, "Service destroyed.") Log.d(this::class.simpleName, "Service destroyed.")
} }
@ -213,7 +217,10 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
player.setMediaItem(item) player.setMediaItem(item)
player.prepare() player.prepare()
player.play()
if (playbackManager.isPlaying) {
player.play()
}
uploadMetadataToSession(it) uploadMetadataToSession(it)
notification.setMetadata(playbackManager.song!!, this) { notification.setMetadata(playbackManager.song!!, this) {
@ -272,6 +279,14 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
player.seekTo(position) player.seekTo(position)
} }
override fun onNeedContextToRestoreState() {
Log.d(this::class.simpleName, "Giving context to PlaybackStateManager")
serviceScope.launch {
playbackManager.getStateFromDatabase(this@PlaybackService)
}
}
// --- OTHER FUNCTIONS --- // --- OTHER FUNCTIONS ---
private fun restorePlayer() { private fun restorePlayer() {
@ -382,7 +397,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
return false 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() { private inner class SystemEventReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val action = intent.action val action = intent.action

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import org.oxycblt.auxio.database.QueueConverter
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.BaseModel
@ -83,6 +84,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// state. // state.
if (playbackManager.song != null) { if (playbackManager.song != null) {
restorePlaybackState() restorePlaybackState()
} else {
playbackManager.needContextToRestoreState()
} }
} }
@ -156,6 +159,19 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playbackManager.prev() 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. // Remove a queue OR user queue item, given a QueueAdapter index.
fun removeQueueItem(adapterIndex: Int, queueAdapter: QueueAdapter) { fun removeQueueItem(adapterIndex: Int, queueAdapter: QueueAdapter) {
var index = adapterIndex.dec() 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.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.applyDivider 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() { class QueueFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()

View file

@ -3,6 +3,14 @@ package org.oxycblt.auxio.playback.state
enum class LoopMode { enum class LoopMode {
NONE, ONCE, INFINITE; NONE, ONCE, INFINITE;
fun toConstant(): Int {
return when (this) {
NONE -> CONSTANT_NONE
ONCE -> CONSTANT_ONCE
INFINITE -> CONSTANT_INFINITE
}
}
fun increment(): LoopMode { fun increment(): LoopMode {
return when (this) { return when (this) {
NONE -> ONCE NONE -> ONCE
@ -10,4 +18,20 @@ enum class LoopMode {
INFINITE -> NONE 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 // IN_ALBUM -> Play from the songs of the album
enum class PlaybackMode { enum class PlaybackMode {
IN_ARTIST, IN_GENRE, IN_ALBUM, ALL_SONGS; 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 package org.oxycblt.auxio.playback.state
import android.content.Context
import android.util.Log 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.BaseModel
@ -348,11 +354,10 @@ class PlaybackStateManager private constructor() {
// can be restored when its started again. // can be restored when its started again.
val newSeed = Random.Default.nextLong() val newSeed = Random.Default.nextLong()
Log.d(this::class.simpleName, "Shuffling queue with a seed of $newSeed.")
mShuffleSeed = 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 mIndex = 0
// If specified, make the current song the first member of the queue. // If specified, make the current song the first member of the queue.
@ -380,6 +385,8 @@ class PlaybackStateManager private constructor() {
} }
mIndex = mQueue.indexOf(mSong) mIndex = mQueue.indexOf(mSong)
forceQueueUpdate()
} }
// --- STATE FUNCTIONS --- // --- STATE FUNCTIONS ---
@ -441,6 +448,106 @@ class PlaybackStateManager private constructor() {
return final 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]. * The interface for receiving updates from [PlaybackStateManager].
* Add the callback to [PlaybackStateManager] using [addCallback], * Add the callback to [PlaybackStateManager] using [addCallback],
@ -458,6 +565,7 @@ class PlaybackStateManager private constructor() {
fun onShuffleUpdate(isShuffling: Boolean) {} fun onShuffleUpdate(isShuffling: Boolean) {}
fun onLoopUpdate(mode: LoopMode) {} fun onLoopUpdate(mode: LoopMode) {}
fun onSeekConfirm(position: Long) {} fun onSeekConfirm(position: Long) {}
fun onNeedContextToRestoreState() {}
} }
companion object { companion object {