From 8b8d36cf225e5f3f152a337c8fdaf7b85ac75806 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Thu, 4 Nov 2021 06:58:43 -0600 Subject: [PATCH] playback: improve persistence Improve playback persistence in the following ways: 1. Shift the boundary of PlaybackStateManager and PlaybackStateDatabase so that the reading and searching phases both occur at the same time, which is more efficient. 2. Improve music hashing so that conflicts are minimized [this also helps the future playlists addition] 3. Generally improve code style --- .../org/oxycblt/auxio/coil/MosaicFetcher.kt | 11 +- .../auxio/detail/AlbumDetailFragment.kt | 2 +- .../auxio/detail/ArtistDetailFragment.kt | 2 +- .../auxio/detail/GenreDetailFragment.kt | 2 +- .../java/org/oxycblt/auxio/music/Models.kt | 38 ++- .../org/oxycblt/auxio/music/MusicStore.kt | 2 +- .../org/oxycblt/auxio/music/MusicUtils.kt | 2 +- .../auxio/playback/PlaybackBarLayout.kt | 3 + .../auxio/playback/PlaybackViewModel.kt | 19 +- .../auxio/playback/state/PlaybackMode.kt | 25 +- .../playback/state/PlaybackStateDatabase.kt | 264 +++++++++--------- .../playback/state/PlaybackStateManager.kt | 148 ++++------ .../playback/system/PlaybackNotification.kt | 4 +- .../auxio/playback/system/PlaybackService.kt | 10 +- .../java/org/oxycblt/auxio/ui/SortMode.kt | 6 +- app/src/main/res/layout/fragment_home.xml | 1 + 16 files changed, 250 insertions(+), 289 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt index de8d6c140..aad2264ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt @@ -32,11 +32,10 @@ import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.size.OriginalSize import coil.size.Size -import okio.source import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Parent +import org.oxycblt.auxio.music.MusicParent import java.io.Closeable import java.lang.Exception @@ -44,10 +43,10 @@ import java.lang.Exception * A [Fetcher] that takes an [Artist] or [Genre] and returns a mosaic of its albums. * @author OxygenCobalt */ -class MosaicFetcher(private val context: Context) : Fetcher { +class MosaicFetcher(private val context: Context) : Fetcher { override suspend fun fetch( pool: BitmapPool, - data: Parent, + data: MusicParent, size: Size, options: Options ): FetchResult { @@ -147,8 +146,8 @@ class MosaicFetcher(private val context: Context) : Fetcher { forEach { it.use(block) } } - override fun key(data: Parent): String = data.hashCode().toString() - override fun handles(data: Parent) = data !is Album // Albums are not used here + override fun key(data: MusicParent): String = data.hashCode().toString() + override fun handles(data: MusicParent) = data !is Album // Albums are not used here companion object { private const val MOSAIC_BITMAP_SIZE = 512 diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 93a323dac..0ae057081 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -137,7 +137,7 @@ class AlbumDetailFragment : DetailFragment() { // --- PLAYBACKVIEWMODEL SETUP --- playbackModel.song.observe(viewLifecycleOwner) { song -> - if (playbackModel.mode.value == PlaybackMode.IN_ALBUM && + if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM && playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id ) { detailAdapter.highlightSong(song, binding.detailRecycler) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 930866ef9..6a3794798 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -131,7 +131,7 @@ class ArtistDetailFragment : DetailFragment() { // Highlight songs if they are being played playbackModel.song.observe(viewLifecycleOwner) { song -> - if (playbackModel.mode.value == PlaybackMode.IN_ARTIST && + if (playbackModel.playbackMode.value == PlaybackMode.IN_ARTIST && playbackModel.parent.value?.id == detailModel.curArtist.value?.id ) { detailAdapter.highlightSong(song, binding.detailRecycler) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 15b92f92e..f900e55a3 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -99,7 +99,7 @@ class GenreDetailFragment : DetailFragment() { // --- PLAYBACKVIEWMODEL SETUP --- playbackModel.song.observe(viewLifecycleOwner) { song -> - if (playbackModel.mode.value == PlaybackMode.IN_GENRE && + if (playbackModel.playbackMode.value == PlaybackMode.IN_GENRE && playbackModel.parent.value?.id == detailModel.curGenre.value!!.id ) { detailAdapter.highlightSong(song, binding.detailRecycler) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Models.kt b/app/src/main/java/org/oxycblt/auxio/music/Models.kt index d35afcadb..c6d7ea2a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Models.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Models.kt @@ -33,16 +33,23 @@ sealed class BaseModel { abstract val id: Long } +/** + * A [BaseModel] variant that represents a music item. + * @property name The raw name of this track + * @property hash A stable, unique-ish hash for this item. Used for database work. + */ sealed class Music : BaseModel() { abstract val name: String - abstract val hash: Int + abstract val hash: Long } /** * [BaseModel] variant that denotes that this object is a parent of other data objects, such * as an [Album] or [Artist] + * @property resolvedName A name resolved from it's raw form to a form suitable to be shown in + * a ui. Ex. unknown would become Unknown Artist, (124) would become its proper genre name, etc. */ -sealed class Parent : Music() { +sealed class MusicParent : Music() { abstract val resolvedName: String } @@ -79,8 +86,10 @@ data class Song( val seconds: Long get() = duration / 1000 val formattedDuration: String get() = (duration / 1000).toDuration() - override val hash: Int get() { - var result = name.hashCode() + override val hash: Long get() { + var result = name.hashCode().toLong() + result = 31 * result + albumName.hashCode() + result = 31 * result + artistName.hashCode() result = 31 * result + track result = 31 * result + duration.hashCode() return result @@ -96,7 +105,7 @@ data class Song( } /** - * The data object for an album. Inherits [Parent]. + * The data object for an album. Inherits [MusicParent]. * @property artistName The name of the parent artist. Do not use this outside of creating the artist from albums * @property year The year this album was released. 0 if there is none in the metadata. * @property artist The Album's parent [Artist]. use this instead of [artistName] @@ -109,7 +118,7 @@ data class Album( val artistName: String, val year: Int, val songs: List -) : Parent() { +) : MusicParent() { init { songs.forEach { song -> song.linkAlbum(this) @@ -126,8 +135,8 @@ data class Album( mArtist = artist } - override val hash: Int get() { - var result = name.hashCode() + override val hash: Long get() { + var result = name.hashCode().toLong() result = 31 * result + artistName.hashCode() result = 31 * result + year return result @@ -138,7 +147,7 @@ data class Album( } /** - * The data object for an artist. Inherits [Parent] + * The data object for an artist. Inherits [MusicParent] * @property albums The list of all [Album]s in this artist * @property genre The most prominent genre for this artist * @property songs The list of all [Song]s in this artist @@ -148,7 +157,7 @@ data class Artist( override val name: String, override val resolvedName: String, val albums: List -) : Parent() { +) : MusicParent() { init { albums.forEach { album -> album.linkArtist(this) @@ -165,18 +174,18 @@ data class Artist( albums.flatMap { it.songs } } - override val hash = name.hashCode() + override val hash = name.hashCode().toLong() } /** - * The data object for a genre. Inherits [Parent] + * The data object for a genre. Inherits [MusicParent] * @property songs The list of all [Song]s in this genre. */ data class Genre( override val id: Long, override val name: String, override val resolvedName: String -) : Parent() { +) : MusicParent() { private val mSongs = mutableListOf() val songs: List get() = mSongs @@ -188,13 +197,14 @@ data class Genre( song.linkGenre(this) } - override val hash = name.hashCode() + override val hash = name.hashCode().toLong() } /** * The string used for a header instance. This class is a bit complex, mostly because it revolves * around passing string resources that are then resolved by the view instead of passing a context * directly. + * @author OxygenCobalt */ sealed class HeaderString { /** A single string resource. */ 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 72e75405f..f1e5c6680 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -92,7 +92,7 @@ class MusicStore private constructor() { /** * Find a song in a faster manner using a hash for its album as well. */ - fun findSongFast(songHash: Int, albumHash: Int): Song? { + fun findSongFast(songHash: Long, albumHash: Long): Song? { return albums.find { it.hash == albumHash }?.songs?.find { it.hash == songHash } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt index f5ab0e5d7..7b52c480e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt @@ -29,7 +29,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.util.getPlural /** - * A complete array of all the hardcoded genre values for ID3 () - private val mParent = MutableLiveData() + private val mParent = MutableLiveData() private val mPosition = MutableLiveData(0L) // Queue @@ -77,16 +77,16 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { /** The current song. */ val song: LiveData get() = mSong /** The current model that is being played from, such as an [Album] or [Artist] */ - val parent: LiveData get() = mParent + val parent: LiveData get() = mParent /** The current playback position, in seconds */ val position: LiveData get() = mPosition - /** The current queue determined by [mode] and [parent] */ + /** The current queue determined by [playbackMode] and [parent] */ val queue: LiveData> get() = mQueue /** The queue created by the user. */ val userQueue: LiveData> get() = mUserQueue /** The current [PlaybackMode] that also determines the queue */ - val mode: LiveData get() = mMode + val playbackMode: LiveData get() = mMode /** Whether playback is originating from the user-generated queue or not */ val isInUserQueue: LiveData = mIsInUserQueue @@ -156,11 +156,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { } } - /** The position as SeekBar progress. */ - val positionAsProgress = Transformations.map(mPosition) { - if (mSong.value != null) it.toInt() else 0 - } - private val playbackManager = PlaybackStateManager.maybeGetInstance() private val settingsManager = SettingsManager.getInstance() @@ -449,7 +444,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { mPosition.value = playbackManager.position / 1000 mParent.value = playbackManager.parent mQueue.value = playbackManager.queue - mMode.value = playbackManager.mode + mMode.value = playbackManager.playbackMode mUserQueue.value = playbackManager.userQueue mIndex.value = playbackManager.index mIsPlaying.value = playbackManager.isPlaying @@ -467,7 +462,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { mSong.value = song } - override fun onParentUpdate(parent: Parent?) { + override fun onParentUpdate(parent: MusicParent?) { mParent.value = parent } 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 46aadbb00..c4809c9ab 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 @@ -24,27 +24,27 @@ package org.oxycblt.auxio.playback.state */ enum class PlaybackMode { /** Construct the queue from the genre's songs */ - IN_GENRE, + ALL_SONGS, /** Construct the queue from the artist's songs */ - IN_ARTIST, - /** Construct the queue from the album's songs */ IN_ALBUM, + /** Construct the queue from the album's songs */ + IN_ARTIST, /** Construct the queue from all songs */ - ALL_SONGS; + IN_GENRE; /** * Convert the mode into an int constant, to be saved in PlaybackStateDatabase * @return The constant for this mode, */ fun toInt(): Int { - return CONST_IN_ARTIST + ordinal + return CONST_ALL_SONGS + ordinal } companion object { - private const val CONST_IN_GENRE = 0xA103 - private const val CONST_IN_ARTIST = 0xA104 - private const val CONST_IN_ALBUM = 0xA105 - private const val CONST_ALL_SONGS = 0xA106 + private const val CONST_ALL_SONGS = 0xA103 + private const val CONST_IN_ALBUM = 0xA104 + private const val CONST_IN_ARTIST = 0xA105 + private const val CONST_IN_GENRE = 0xA106 /** * Get a [PlaybackMode] for an int [constant] @@ -52,11 +52,10 @@ enum class PlaybackMode { */ fun fromInt(constant: Int): PlaybackMode? { return when (constant) { - CONST_IN_ARTIST -> IN_ARTIST - CONST_IN_ALBUM -> IN_ALBUM - CONST_IN_GENRE -> IN_GENRE CONST_ALL_SONGS -> ALL_SONGS - + CONST_IN_ALBUM -> IN_ALBUM + CONST_IN_ARTIST -> IN_ARTIST + CONST_IN_GENRE -> IN_GENRE else -> null } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt index 13a0fdc6b..a1f7ca0b3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt @@ -22,7 +22,11 @@ import android.content.ContentValues import android.content.Context 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.MusicParent +import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.assertBackgroundThread import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.queryAll @@ -30,8 +34,7 @@ import org.oxycblt.auxio.util.queryAll /** * 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. - * TODO: Improve the boundary between this and [PlaybackStateManager]. This would be more - * efficient. + * LEFT-OFF: Improve hashing by making everything a long * @author OxygenCobalt */ class PlaybackStateDatabase(context: Context) : @@ -74,30 +77,30 @@ class PlaybackStateDatabase(context: Context) : } /** - * Construct a [DatabaseState] table + * Construct a [StateColumns] table */ private fun constructStateTable(command: StringBuilder): StringBuilder { - command.append("${DatabaseState.COLUMN_ID} LONG PRIMARY KEY,") - .append("${DatabaseState.COLUMN_SONG_HASH} INTEGER NOT NULL,") - .append("${DatabaseState.COLUMN_POSITION} LONG NOT NULL,") - .append("${DatabaseState.COLUMN_PARENT_HASH} INTEGER NOT NULL,") - .append("${DatabaseState.COLUMN_INDEX} INTEGER NOT NULL,") - .append("${DatabaseState.COLUMN_MODE} INTEGER NOT NULL,") - .append("${DatabaseState.COLUMN_IS_SHUFFLING} BOOLEAN NOT NULL,") - .append("${DatabaseState.COLUMN_LOOP_MODE} INTEGER NOT NULL,") - .append("${DatabaseState.COLUMN_IN_USER_QUEUE} BOOLEAN NOT NULL)") + command.append("${StateColumns.COLUMN_ID} LONG PRIMARY KEY,") + .append("${StateColumns.COLUMN_SONG_HASH} 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_PLAYBACK_MODE} INTEGER NOT NULL,") + .append("${StateColumns.COLUMN_IS_SHUFFLING} BOOLEAN NOT NULL,") + .append("${StateColumns.COLUMN_LOOP_MODE} INTEGER NOT NULL,") + .append("${StateColumns.COLUMN_IS_IN_USER_QUEUE} BOOLEAN NOT NULL)") return command } /** - * Construct a [DatabaseQueueItem] table + * Construct a [QueueColumns] table */ private fun constructQueueTable(command: StringBuilder): StringBuilder { - command.append("${DatabaseQueueItem.COLUMN_ID} LONG PRIMARY KEY,") - .append("${DatabaseQueueItem.COLUMN_SONG_HASH} INTEGER NOT NULL,") - .append("${DatabaseQueueItem.COLUMN_ALBUM_HASH} INTEGER NOT NULL,") - .append("${DatabaseQueueItem.COLUMN_IS_USER_QUEUE} BOOLEAN NOT NULL)") + command.append("${QueueColumns.ID} LONG PRIMARY KEY,") + .append("${QueueColumns.SONG_HASH} INTEGER NOT NULL,") + .append("${QueueColumns.ALBUM_HASH} INTEGER NOT NULL,") + .append("${QueueColumns.IS_USER_QUEUE} BOOLEAN NOT NULL)") return command } @@ -105,9 +108,9 @@ class PlaybackStateDatabase(context: Context) : // --- INTERFACE FUNCTIONS --- /** - * Clear the previously written [DatabaseState] and write a new one. + * Clear the previously written [SavedState] and write a new one. */ - fun writeState(state: DatabaseState) { + fun writeState(state: SavedState) { assertBackgroundThread() writableDatabase.transaction { @@ -116,15 +119,15 @@ class PlaybackStateDatabase(context: Context) : this@PlaybackStateDatabase.logD("Wiped state db.") val stateData = ContentValues(10).apply { - put(DatabaseState.COLUMN_ID, state.id) - put(DatabaseState.COLUMN_SONG_HASH, state.songHash) - put(DatabaseState.COLUMN_POSITION, state.position) - put(DatabaseState.COLUMN_PARENT_HASH, state.parentHash) - put(DatabaseState.COLUMN_INDEX, state.index) - put(DatabaseState.COLUMN_MODE, state.mode) - put(DatabaseState.COLUMN_IS_SHUFFLING, state.isShuffling) - put(DatabaseState.COLUMN_LOOP_MODE, state.loopMode) - put(DatabaseState.COLUMN_IN_USER_QUEUE, state.inUserQueue) + put(StateColumns.COLUMN_ID, 0) + put(StateColumns.COLUMN_SONG_HASH, state.song?.hash) + put(StateColumns.COLUMN_POSITION, state.position) + put(StateColumns.COLUMN_PARENT_HASH, state.parent?.hash) + put(StateColumns.COLUMN_QUEUE_INDEX, state.queueIndex) + put(StateColumns.COLUMN_PLAYBACK_MODE, state.playbackMode.toInt()) + put(StateColumns.COLUMN_IS_SHUFFLING, state.isShuffling) + put(StateColumns.COLUMN_LOOP_MODE, state.loopMode.toInt()) + put(StateColumns.COLUMN_IS_IN_USER_QUEUE, state.isInUserQueue) } insert(TABLE_NAME_STATE, null, stateData) @@ -134,39 +137,55 @@ class PlaybackStateDatabase(context: Context) : } /** - * Read the stored [DatabaseState] from the database, if there is one. - * @return The stored [DatabaseState], null if there isn't one. + * Read the stored [SavedState] from the database, if there is one. + * @param musicStore Required to transform database songs/parents into actual instances + * @return The stored [SavedState], null if there isn't one. */ - fun readState(): DatabaseState? { + fun readState(musicStore: MusicStore): SavedState? { assertBackgroundThread() - var state: DatabaseState? = null + var state: SavedState? = null readableDatabase.queryAll(TABLE_NAME_STATE) { cursor -> if (cursor.count == 0) return@queryAll - val songIndex = cursor.getColumnIndexOrThrow(DatabaseState.COLUMN_SONG_HASH) - val posIndex = cursor.getColumnIndexOrThrow(DatabaseState.COLUMN_POSITION) - val parentIndex = cursor.getColumnIndexOrThrow(DatabaseState.COLUMN_PARENT_HASH) - val indexIndex = cursor.getColumnIndexOrThrow(DatabaseState.COLUMN_INDEX) - val modeIndex = cursor.getColumnIndexOrThrow(DatabaseState.COLUMN_MODE) - val shuffleIndex = cursor.getColumnIndexOrThrow(DatabaseState.COLUMN_IS_SHUFFLING) - val loopModeIndex = cursor.getColumnIndexOrThrow(DatabaseState.COLUMN_LOOP_MODE) - val inUserQueueIndex = cursor.getColumnIndexOrThrow( - DatabaseState.COLUMN_IN_USER_QUEUE + 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_SHUFFLING) + val loopModeIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_LOOP_MODE) + val isInUserQueueIndex = cursor.getColumnIndexOrThrow( + StateColumns.COLUMN_IS_IN_USER_QUEUE ) cursor.moveToFirst() - state = DatabaseState( - songHash = cursor.getInt(songIndex), + val song = cursor.getLongOrNull(songIndex)?.let { hash -> + musicStore.songs.find { it.hash == hash } + } + + val mode = PlaybackMode.fromInt(cursor.getInt(modeIndex)) ?: PlaybackMode.ALL_SONGS + + val parent = cursor.getLongOrNull(parentIndex)?.let { hash -> + when (mode) { + PlaybackMode.IN_GENRE -> musicStore.genres.find { it.hash == hash } + PlaybackMode.IN_ARTIST -> musicStore.artists.find { it.hash == hash } + PlaybackMode.IN_ALBUM -> musicStore.albums.find { it.hash == hash } + PlaybackMode.ALL_SONGS -> null + } + } + + state = SavedState( + song = song, position = cursor.getLong(posIndex), - parentHash = cursor.getInt(parentIndex), - index = cursor.getInt(indexIndex), - mode = cursor.getInt(modeIndex), + parent = parent, + queueIndex = cursor.getInt(indexIndex), + playbackMode = mode, isShuffling = cursor.getInt(shuffleIndex) == 1, - loopMode = cursor.getInt(loopModeIndex), - inUserQueue = cursor.getInt(inUserQueueIndex) == 1 + loopMode = LoopMode.fromInt(cursor.getInt(loopModeIndex)) ?: LoopMode.NONE, + isInUserQueue = cursor.getInt(isInUserQueueIndex) == 1 ) } @@ -174,9 +193,9 @@ class PlaybackStateDatabase(context: Context) : } /** - * Write a list of [queueItems] to the database, clearing the previous queue present. + * Write a [SavedQueue] to the database. */ - fun writeQueue(queueItems: List) { + fun writeQueue(queue: SavedQueue) { assertBackgroundThread() val database = writableDatabase @@ -187,22 +206,29 @@ class PlaybackStateDatabase(context: Context) : logD("Wiped queue db.") + writeQueueBatch(queue.user, true, 0) + writeQueueBatch(queue.queue, false, queue.user.size) + } + + private fun writeQueueBatch(queue: List, isUserQueue: Boolean, idStart: Int) { + logD("Beginning queue write [start: $idStart, userQueue: $isUserQueue]") + + val database = writableDatabase var position = 0 - // Try to write out the entirety of the queue. - while (position < queueItems.size) { + while (position < queue.size) { var i = position database.transaction { - while (i < queueItems.size) { - val item = queueItems[i] + while (i < queue.size) { + val song = queue[i] i++ val itemData = ContentValues(4).apply { - put(DatabaseQueueItem.COLUMN_ID, item.id) - put(DatabaseQueueItem.COLUMN_SONG_HASH, item.songHash) - put(DatabaseQueueItem.COLUMN_ALBUM_HASH, item.albumHash) - put(DatabaseQueueItem.COLUMN_IS_USER_QUEUE, item.isUserQueue) + put(QueueColumns.ID, idStart + i) + put(QueueColumns.SONG_HASH, song.hash) + put(QueueColumns.ALBUM_HASH, song.album.hash) + put(QueueColumns.IS_USER_QUEUE, isUserQueue) } insert(TABLE_NAME_QUEUE, null, itemData) @@ -218,38 +244,70 @@ class PlaybackStateDatabase(context: Context) : } /** - * Read the database for any [DatabaseQueueItem]s. - * @return A list of any stored [DatabaseQueueItem]s. + * Read a [SavedQueue] from this database. + * @param musicStore Required to transform database songs into actual song instances */ - fun readQueue(): List { + fun readQueue(musicStore: MusicStore): SavedQueue { assertBackgroundThread() - val queueItems = mutableListOf() + val queue = SavedQueue(mutableListOf(), mutableListOf()) readableDatabase.queryAll(TABLE_NAME_QUEUE) { cursor -> if (cursor.count == 0) return@queryAll - val idIndex = cursor.getColumnIndexOrThrow(DatabaseQueueItem.COLUMN_ID) - val songIdIndex = cursor.getColumnIndexOrThrow(DatabaseQueueItem.COLUMN_SONG_HASH) - val albumIdIndex = cursor.getColumnIndexOrThrow(DatabaseQueueItem.COLUMN_ALBUM_HASH) - val isUserQueueIndex = cursor.getColumnIndexOrThrow(DatabaseQueueItem.COLUMN_IS_USER_QUEUE) + val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_HASH) + val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_HASH) + val isUserQueueIndex = cursor.getColumnIndexOrThrow(QueueColumns.IS_USER_QUEUE) while (cursor.moveToNext()) { - queueItems += DatabaseQueueItem( - id = cursor.getLong(idIndex), - songHash = cursor.getInt(songIdIndex), - albumHash = cursor.getInt(albumIdIndex), - isUserQueue = cursor.getInt(isUserQueueIndex) == 1 - ) + musicStore.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))?.let { song -> + if (cursor.getInt(isUserQueueIndex) == 1) { + queue.user.add(song) + } else { + queue.queue.add(song) + } + } } } - return queueItems + return queue + } + + data class SavedState( + val song: Song?, + val position: Long, + val parent: MusicParent?, + val queueIndex: Int, + val playbackMode: PlaybackMode, + val isShuffling: Boolean, + val loopMode: LoopMode, + val isInUserQueue: Boolean + ) + + data class SavedQueue(val user: MutableList, val queue: MutableList) + + private object StateColumns { + const val COLUMN_ID = "id" + const val COLUMN_SONG_HASH = "song" + const val COLUMN_POSITION = "position" + const val COLUMN_PARENT_HASH = "parent" + const val COLUMN_QUEUE_INDEX = "queue_index" + const val COLUMN_PLAYBACK_MODE = "playback_mode" + const val COLUMN_IS_SHUFFLING = "is_shuffling" + const val COLUMN_LOOP_MODE = "loop_mode" + const val COLUMN_IS_IN_USER_QUEUE = "is_in_user_queue" + } + + private object QueueColumns { + const val ID = "id" + const val SONG_HASH = "song" + const val ALBUM_HASH = "album" + const val IS_USER_QUEUE = "is_user_queue" } companion object { const val DB_NAME = "auxio_state_database.db" - const val DB_VERSION = 4 + const val DB_VERSION = 5 const val TABLE_NAME_STATE = "playback_state_table" const val TABLE_NAME_QUEUE = "queue_table" @@ -275,61 +333,3 @@ class PlaybackStateDatabase(context: Context) : } } } - -/** - * A database entity that stores a simplified representation of a song in a queue. - * @property id The database entity's id - * @property songHash The hash for the song represented - * @property albumHash The hash for the album represented - * @property isUserQueue A bool for if this queue item is a user queue item or not - * @author OxygenCobalt - */ -data class DatabaseQueueItem( - var id: Long = 0L, - val songHash: Int, - val albumHash: Int, - val isUserQueue: Boolean = false -) { - companion object { - const val COLUMN_ID = "id" - const val COLUMN_SONG_HASH = "song" - const val COLUMN_ALBUM_HASH = "album" - const val COLUMN_IS_USER_QUEUE = "is_user_queue" - } -} - -/** - * A database entity that stores a compressed variant of the current playback state. - * @property id - The database key for this state - * @property songHash - The hash for the currently playing song - * @property parentHash - The hash for the currently playing parent - * @property index - The current index in the queue. - * @property mode - The integer form of the current [org.oxycblt.auxio.playback.state.PlaybackMode] - * @property isShuffling - A bool for if the queue was shuffled - * @property loopMode - The integer form of the current [org.oxycblt.auxio.playback.state.LoopMode] - * @property inUserQueue - A bool for if the state was currently playing from the user queue. - * @author OxygenCobalt - */ -data class DatabaseState( - val id: Long = 0L, - val songHash: Int, - val position: Long, - val parentHash: Int, - val index: Int, - val mode: Int, - val isShuffling: Boolean, - val loopMode: Int, - val inUserQueue: Boolean -) { - companion object { - const val COLUMN_ID = "state_id" - const val COLUMN_SONG_HASH = "song" - const val COLUMN_POSITION = "position" - const val COLUMN_PARENT_HASH = "parent" - const val COLUMN_INDEX = "_index" - const val COLUMN_MODE = "mode" - const val COLUMN_IS_SHUFFLING = "is_shuffling" - const val COLUMN_LOOP_MODE = "loop_mode" - const val COLUMN_IN_USER_QUEUE = "is_user_queue" - } -} 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 6920be408..6412f352a 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 @@ -24,8 +24,8 @@ import kotlinx.coroutines.withContext 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.Parent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.util.logD @@ -53,7 +53,7 @@ class PlaybackStateManager private constructor() { field = value callbacks.forEach { it.onPositionUpdate(value) } } - private var mParent: Parent? = null + private var mParent: MusicParent? = null set(value) { field = value callbacks.forEach { it.onParentUpdate(value) } @@ -75,7 +75,7 @@ class PlaybackStateManager private constructor() { field = value callbacks.forEach { it.onIndexUpdate(value) } } - private var mMode = PlaybackMode.ALL_SONGS + private var mPlaybackMode = PlaybackMode.ALL_SONGS set(value) { field = value callbacks.forEach { it.onModeUpdate(value) } @@ -109,17 +109,17 @@ class PlaybackStateManager private constructor() { /** The currently playing song. Null if there isn't one */ val song: Song? get() = mSong /** The parent the queue is based on, null if all_songs */ - val parent: Parent? get() = mParent + val parent: MusicParent? get() = mParent /** The current playback progress */ val position: Long get() = mPosition - /** The current queue determined by [parent] and [mode] */ + /** The current queue determined by [parent] and [playbackMode] */ val queue: List get() = mQueue /** The queue created by the user. */ val userQueue: List get() = mUserQueue /** The current index of the queue */ val index: Int get() = mIndex /** The current [PlaybackMode] */ - val mode: PlaybackMode get() = mMode + val playbackMode: PlaybackMode get() = mPlaybackMode /** Whether playback is paused or not */ val isPlaying: Boolean get() = mIsPlaying /** Whether the queue is shuffled */ @@ -194,7 +194,7 @@ class PlaybackStateManager private constructor() { } } - mMode = mode + mPlaybackMode = mode updatePlayback(song) // Keep shuffle on, if enabled @@ -205,7 +205,7 @@ class PlaybackStateManager private constructor() { * Play a [parent], such as an artist or album. * @param shuffled Whether the queue is shuffled or not */ - fun playParent(parent: Parent, shuffled: Boolean) { + fun playParent(parent: MusicParent, shuffled: Boolean) { logD("Playing ${parent.name}") mParent = parent @@ -214,17 +214,17 @@ class PlaybackStateManager private constructor() { when (parent) { is Album -> { mQueue = parent.songs.toMutableList() - mMode = PlaybackMode.IN_ALBUM + mPlaybackMode = PlaybackMode.IN_ALBUM } is Artist -> { mQueue = parent.songs.toMutableList() - mMode = PlaybackMode.IN_ARTIST + mPlaybackMode = PlaybackMode.IN_ARTIST } is Genre -> { mQueue = parent.songs.toMutableList() - mMode = PlaybackMode.IN_GENRE + mPlaybackMode = PlaybackMode.IN_GENRE } } @@ -238,7 +238,7 @@ class PlaybackStateManager private constructor() { fun shuffleAll() { val musicStore = MusicStore.maybeGetInstance() ?: return - mMode = PlaybackMode.ALL_SONGS + mPlaybackMode = PlaybackMode.ALL_SONGS mQueue = musicStore.songs.toMutableList() mParent = null @@ -471,7 +471,7 @@ class PlaybackStateManager private constructor() { val musicStore = MusicStore.requireInstance() - mQueue = when (mMode) { + mQueue = when (mPlaybackMode) { PlaybackMode.ALL_SONGS -> settingsManager.libSongSort.sortSongs(musicStore.songs).toMutableList() PlaybackMode.IN_ALBUM -> @@ -537,6 +537,14 @@ class PlaybackStateManager private constructor() { setPlaying(true) } + /** + * Loop playback around to the beginning. + */ + fun loop() { + seekTo(0) + setPlaying(!settingsManager.pauseOnLoop) + } + /** * Set the [LoopMode] to [mode]. */ @@ -573,8 +581,16 @@ class PlaybackStateManager private constructor() { val database = PlaybackStateDatabase.getInstance(context) - database.writeState(packToPlaybackState()) - database.writeQueue(packQueue()) + logD("$mPlaybackMode") + + database.writeState( + PlaybackStateDatabase.SavedState( + mSong, mPosition, mParent, mIndex, + mPlaybackMode, mIsShuffling, mLoopMode, mIsInUserQueue + ) + ) + + database.writeQueue(PlaybackStateDatabase.SavedQueue(mUserQueue, mQueue)) this@PlaybackStateManager.logD( "Save finished in ${System.currentTimeMillis() - start}ms" @@ -589,28 +605,28 @@ class PlaybackStateManager private constructor() { suspend fun restoreFromDatabase(context: Context) { logD("Getting state from DB.") + val musicStore = MusicStore.requireInstance() + val start: Long - val playbackState: DatabaseState? - val queueItems: List + val playbackState: PlaybackStateDatabase.SavedState? + val queue: PlaybackStateDatabase.SavedQueue withContext(Dispatchers.IO) { start = System.currentTimeMillis() val database = PlaybackStateDatabase.getInstance(context) - playbackState = database.readState() - queueItems = database.readQueue() + playbackState = database.readState(musicStore) + queue = database.readQueue(musicStore) } // Get off the IO coroutine since it will cause LiveData updates to throw an exception if (playbackState != null) { - logD("Found playback state $playbackState with queue size ${queueItems.size}") + logD("Found playback state $playbackState with queue size ${queue.user.size + queue.queue.size}") - val musicStore = MusicStore.requireInstance() - - unpackFromPlaybackState(playbackState, musicStore) - unpackQueue(queueItems, musicStore) + unpackFromPlaybackState(playbackState) + unpackQueue(queue) doParentSanityCheck() } @@ -619,78 +635,32 @@ class PlaybackStateManager private constructor() { markRestored() } - /** - * Pack the current state into a [DatabaseState] to be saved. - * @return A [DatabaseState] reflecting the current state. - */ - private fun packToPlaybackState(): DatabaseState { - return DatabaseState( - songHash = mSong?.hash ?: Int.MIN_VALUE, - position = mPosition, - parentHash = mParent?.hash ?: Int.MIN_VALUE, - index = mIndex, - mode = mMode.toInt(), - isShuffling = mIsShuffling, - loopMode = mLoopMode.toInt(), - inUserQueue = mIsInUserQueue - ) - } - /** * Unpack a [playbackState] into this instance. */ - private fun unpackFromPlaybackState(playbackState: DatabaseState, musicStore: MusicStore) { + private fun unpackFromPlaybackState(playbackState: PlaybackStateDatabase.SavedState) { // Turn the simplified information from PlaybackState into usable data. // Do queue setup first - mMode = PlaybackMode.fromInt(playbackState.mode) ?: PlaybackMode.ALL_SONGS - mParent = findParent(playbackState.parentHash, mMode, musicStore) - mIndex = playbackState.index + mPlaybackMode = playbackState.playbackMode + mParent = playbackState.parent + mIndex = playbackState.queueIndex // Then set up the current state - mSong = musicStore.songs.find { it.hash == playbackState.songHash } - mLoopMode = LoopMode.fromInt(playbackState.loopMode) ?: LoopMode.NONE + mSong = playbackState.song + mLoopMode = playbackState.loopMode mIsShuffling = playbackState.isShuffling - mIsInUserQueue = playbackState.inUserQueue + mIsInUserQueue = playbackState.isInUserQueue seekTo(playbackState.position) } - /** - * Pack the queue into a list of [DatabaseQueueItem]s to be saved. - * @return A list of packed queue items. - */ - private fun packQueue(): List { - val unified = mutableListOf() - var queueItemId = 0L - - mUserQueue.forEach { song -> - unified.add(DatabaseQueueItem(queueItemId, song.hash, song.album.hash, true)) - queueItemId++ - } - - mQueue.forEach { song -> - unified.add(DatabaseQueueItem(queueItemId, song.hash, song.album.hash, false)) - queueItemId++ - } - - return unified - } - /** * Unpack a list of queue items into a queue & user queue. - * @param queueItems The list of [DatabaseQueueItem]s to unpack. */ - private fun unpackQueue(queueItems: List, musicStore: MusicStore) { - for (item in queueItems) { - musicStore.findSongFast(item.songHash, item.albumHash)?.let { song -> - if (item.isUserQueue) { - mUserQueue.add(song) - } else { - mQueue.add(song) - } - } - } + private fun unpackQueue(queue: PlaybackStateDatabase.SavedQueue) { + mUserQueue = queue.user + mQueue = queue.queue // When done, get a more accurate index to prevent issues with queue songs that were saved // to the db but are now deleted when the restore occurred. @@ -706,27 +676,15 @@ class PlaybackStateManager private constructor() { forceUserQueueUpdate() } - /** - * Get a [Parent] from music store given a [hash] and PlaybackMode [mode]. - */ - private fun findParent(hash: Int, mode: PlaybackMode, musicStore: MusicStore): Parent? { - return when (mode) { - PlaybackMode.IN_GENRE -> musicStore.genres.find { it.hash == hash } - PlaybackMode.IN_ARTIST -> musicStore.artists.find { it.hash == hash } - PlaybackMode.IN_ALBUM -> musicStore.albums.find { it.hash == hash } - PlaybackMode.ALL_SONGS -> null - } - } - /** * Do the sanity check to make sure the parent was not lost in the restore process. */ private fun doParentSanityCheck() { // Check if the parent was lost while in the DB. - if (mSong != null && mParent == null && mMode != PlaybackMode.ALL_SONGS) { + if (mSong != null && mParent == null && mPlaybackMode != PlaybackMode.ALL_SONGS) { logD("Parent lost, attempting restore.") - mParent = when (mMode) { + mParent = when (mPlaybackMode) { PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album PlaybackMode.IN_ARTIST -> mQueue.firstOrNull()?.album?.artist PlaybackMode.IN_GENRE -> mQueue.firstOrNull()?.genre @@ -742,7 +700,7 @@ class PlaybackStateManager private constructor() { */ interface Callback { fun onSongUpdate(song: Song?) {} - fun onParentUpdate(parent: Parent?) {} + fun onParentUpdate(parent: MusicParent?) {} fun onPositionUpdate(position: Long) {} fun onQueueUpdate(queue: List) {} fun onUserQueueUpdate(userQueue: List) {} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt index 080165b17..ef47287ae 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt @@ -29,7 +29,7 @@ import androidx.core.app.NotificationCompat import androidx.media.app.NotificationCompat.MediaStyle import org.oxycblt.auxio.R import org.oxycblt.auxio.coil.loadBitmap -import org.oxycblt.auxio.music.Parent +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.util.newBroadcastIntent @@ -118,7 +118,7 @@ class PlaybackNotification private constructor( /** * Apply the current [parent] to the header of the notification. */ - fun setParent(parent: Parent?) { + fun setParent(parent: MusicParent?) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return // A blank parent always means that the mode is ALL_SONGS diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index e8838881f..cf096494b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -52,7 +52,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.Parent +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.toURI import org.oxycblt.auxio.playback.state.LoopMode @@ -223,11 +223,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac Player.STATE_ENDED -> { if (playbackManager.loopMode == LoopMode.TRACK) { - playbackManager.rewind() - - if (settingsManager.pauseOnLoop) { - playbackManager.setPlaying(false) - } + playbackManager.loop() } else { playbackManager.next() } @@ -270,7 +266,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac stopForegroundAndNotification() } - override fun onParentUpdate(parent: Parent?) { + override fun onParentUpdate(parent: MusicParent?) { notification.setParent(parent) startForegroundOrNotify() diff --git a/app/src/main/java/org/oxycblt/auxio/ui/SortMode.kt b/app/src/main/java/org/oxycblt/auxio/ui/SortMode.kt index 3b3505664..7f0a82513 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/SortMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/SortMode.kt @@ -23,7 +23,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Parent +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song /** @@ -99,14 +99,14 @@ enum class SortMode(@IdRes val itemId: Int) { } /** - * Sort a generic list of [Parent] instances. + * Sort a generic list of [MusicParent] instances. * * **Behavior:** * - [ASCENDING]: By name after article, ascending * - [DESCENDING]: By name after article, descending * - Same parent list is returned otherwise. */ - fun sortParents(parents: Collection): List { + fun sortParents(parents: Collection): List { return when (this) { ASCENDING -> parents.sortedWith( compareBy(String.CASE_INSENSITIVE_ORDER) { model -> diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 0e3cad676..8f592a48b 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -33,6 +33,7 @@ android:background="@android:color/transparent" app:tabContentStart="@dimen/spacing_medium" app:tabMode="scrollable" + app:tabGravity="start" app:tabTextAppearance="@style/TextAppearance.Auxio.TabLayout.Label" app:tabTextColor="@color/sel_accented_primary"/>