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:
parent
ee95bc1a9e
commit
6d809f4303
14 changed files with 355 additions and 7 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue