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
This commit is contained in:
OxygenCobalt 2021-11-04 06:58:43 -06:00
parent 10c45f1492
commit 8b8d36cf22
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
16 changed files with 250 additions and 289 deletions

View file

@ -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<Parent> {
class MosaicFetcher(private val context: Context) : Fetcher<MusicParent> {
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<Parent> {
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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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<Song>
) : 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<Album>
) : 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<Song>()
val songs: List<Song> 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. */

View file

@ -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 }
}

View file

@ -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 <v3, contains standard genres and
* A complete array of all the hardcoded genre values for ID3(v2), contains standard genres and
* winamp extensions.
*/
private val ID3_GENRES = arrayOf(

View file

@ -38,6 +38,9 @@ import org.oxycblt.auxio.util.systemBarsCompat
* properly. The mechanism is mostly inspired by Material Files' PersistentBarLayout, however
* this class was primarily written by me and I plan to expand this layout to become part of
* the playback navigation process.
*
* TODO: Explain how this thing works so that others can be spared the pain of deciphering
* this custom viewgroup
*/
class PlaybackBarLayout @JvmOverloads constructor(
context: Context,

View file

@ -35,8 +35,8 @@ import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.HeaderString
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.playback.queue.QueueAdapter
import org.oxycblt.auxio.playback.state.LoopMode
@ -56,7 +56,7 @@ import org.oxycblt.auxio.util.logE
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// Playback
private val mSong = MutableLiveData<Song?>()
private val mParent = MutableLiveData<Parent?>()
private val mParent = MutableLiveData<MusicParent?>()
private val mPosition = MutableLiveData(0L)
// Queue
@ -77,16 +77,16 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
/** The current song. */
val song: LiveData<Song?> get() = mSong
/** The current model that is being played from, such as an [Album] or [Artist] */
val parent: LiveData<Parent?> get() = mParent
val parent: LiveData<MusicParent?> get() = mParent
/** The current playback position, in seconds */
val position: LiveData<Long> get() = mPosition
/** The current queue determined by [mode] and [parent] */
/** The current queue determined by [playbackMode] and [parent] */
val queue: LiveData<List<Song>> get() = mQueue
/** The queue created by the user. */
val userQueue: LiveData<List<Song>> get() = mUserQueue
/** The current [PlaybackMode] that also determines the queue */
val mode: LiveData<PlaybackMode> get() = mMode
val playbackMode: LiveData<PlaybackMode> get() = mMode
/** Whether playback is originating from the user-generated queue or not */
val isInUserQueue: LiveData<Boolean> = 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
}

View file

@ -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
}
}

View file

@ -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<DatabaseQueueItem>) {
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<Song>, 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<DatabaseQueueItem> {
fun readQueue(musicStore: MusicStore): SavedQueue {
assertBackgroundThread()
val queueItems = mutableListOf<DatabaseQueueItem>()
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<Song>, val queue: MutableList<Song>)
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"
}
}

View file

@ -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<Song> get() = mQueue
/** The queue created by the user. */
val userQueue: List<Song> 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<DatabaseQueueItem>
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<DatabaseQueueItem> {
val unified = mutableListOf<DatabaseQueueItem>()
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<DatabaseQueueItem>, 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<Song>) {}
fun onUserQueueUpdate(userQueue: List<Song>) {}

View file

@ -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

View file

@ -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()

View file

@ -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 <T : Parent> sortParents(parents: Collection<T>): List<T> {
fun <T : MusicParent> sortParents(parents: Collection<T>): List<T> {
return when (this) {
ASCENDING -> parents.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { model ->

View file

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