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"/>