From 6d809f43039b03e05a6e07b5858dd4ac75550de0 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sat, 14 Nov 2020 16:53:35 -0700 Subject: [PATCH] Implement persistence Implement the ability for [most of] the playback state to persist even after the app's process has been killed. --- app/build.gradle | 7 ++ .../oxycblt/auxio/database/PlaybackState.kt | 41 +++++++ .../auxio/database/PlaybackStateDAO.kt | 21 ++++ .../auxio/database/PlaybackStateDatabase.kt | 37 ++++++ .../oxycblt/auxio/database/QueueConverter.kt | 30 +++++ .../org/oxycblt/auxio/music/MusicStore.kt | 8 ++ .../auxio/playback/CompactPlaybackFragment.kt | 2 +- .../auxio/playback/NotificationUtils.kt | 6 + .../oxycblt/auxio/playback/PlaybackService.kt | 23 +++- .../auxio/playback/PlaybackViewModel.kt | 16 +++ .../auxio/playback/queue/QueueFragment.kt | 6 + .../oxycblt/auxio/playback/state/LoopMode.kt | 24 ++++ .../auxio/playback/state/PlaybackMode.kt | 27 +++++ .../playback/state/PlaybackStateManager.kt | 114 +++++++++++++++++- 14 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/database/PlaybackState.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/database/PlaybackStateDAO.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/database/PlaybackStateDatabase.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/database/QueueConverter.kt diff --git a/app/build.gradle b/app/build.gradle index ad870864a..60b5954dc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/database/PlaybackState.kt b/app/src/main/java/org/oxycblt/auxio/database/PlaybackState.kt new file mode 100644 index 000000000..5b5c4689a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/database/PlaybackState.kt @@ -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 +) diff --git a/app/src/main/java/org/oxycblt/auxio/database/PlaybackStateDAO.kt b/app/src/main/java/org/oxycblt/auxio/database/PlaybackStateDAO.kt new file mode 100644 index 000000000..f128653cd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/database/PlaybackStateDAO.kt @@ -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 + + @Query("DELETE FROM playback_state_table") + fun clear() +} diff --git a/app/src/main/java/org/oxycblt/auxio/database/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/database/PlaybackStateDatabase.kt new file mode 100644 index 000000000..219a253f1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/database/PlaybackStateDatabase.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/database/QueueConverter.kt b/app/src/main/java/org/oxycblt/auxio/database/QueueConverter.kt new file mode 100644 index 000000000..c390be0d0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/database/QueueConverter.kt @@ -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 { + val jsonArray = JSONArray(arrayString) + val queue = mutableListOf() + 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): String { + val jsonArray = JSONArray() + queueIds.forEach { + jsonArray.put(it) + } + return jsonArray.toString(0) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index c38129469..32e8cc26d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -24,6 +24,14 @@ class MusicStore private constructor() { private var mSongs = listOf() val songs: List get() = mSongs + val parents: MutableList by lazy { + val parents = mutableListOf() + parents.addAll(mGenres) + parents.addAll(mArtists) + parents.addAll(mAlbums) + parents + } + var loaded = false private set diff --git a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt index 552371da9..b62e770d6 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt @@ -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() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/NotificationUtils.kt b/app/src/main/java/org/oxycblt/auxio/playback/NotificationUtils.kt index 1cdfdb6f7..9c88c4d87 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/NotificationUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/NotificationUtils.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt index dba3c6cad..dd199ba38 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt @@ -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() - player.play() + + 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 diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index b726c4f85..8b69dfaa4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -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().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() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 4681d1145..6f902641d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -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() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/LoopMode.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/LoopMode.kt index 983b4f548..31f9888c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/LoopMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/LoopMode.kt @@ -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 + } + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackMode.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackMode.kt index 59adc65df..c71155c65 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackMode.kt @@ -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 + } + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 5859082a0..9c54e3ff9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -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().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 {