Move playback state system to hashes

Use unique-ish hashes in the playback state system instead of the less efficent and less reliable string system.
This cuts save times in ~half and improves restore times by ~1/3.

Yeah, this is like the 4th time I've changed this system but unless I have some major loader refactor I
think this wont change again.
This commit is contained in:
OxygenCobalt 2021-04-14 11:14:17 -06:00
parent 969f25176a
commit 17e5aed131
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 121 additions and 96 deletions

View file

@ -6,6 +6,7 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.sqlite.transaction
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.ui.assertBackgroundThread
/**
* Database for storing blacklisted paths.

View file

@ -2,7 +2,6 @@ package org.oxycblt.auxio.database
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.os.Looper
/**
* Shortcut for querying all items in a database and running [block] with the cursor returned.
@ -10,12 +9,3 @@ import android.os.Looper
*/
fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
query(tableName, null, null, null, null, null, null)?.use(block)
/**
* Assert that we are on a background thread.
*/
fun assertBackgroundThread() {
check(Looper.myLooper() != Looper.getMainLooper()) {
"Database operations must be ran on a background thread."
}
}

View file

@ -3,8 +3,8 @@ package org.oxycblt.auxio.database
/**
* A database entity that stores a compressed variant of the current playback state.
* @property id - The database key for this state
* @property songName - The song that is currently playing
* @property parentName - The parent that is being played from [-1 if none]
* @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
@ -14,10 +14,9 @@ package org.oxycblt.auxio.database
*/
data class PlaybackState(
val id: Long = 0L,
val songName: String = "",
val songAlbumName: String = "",
val songHash: Int,
val position: Long,
val parentName: String = "",
val parentHash: Int,
val index: Int,
val mode: Int,
val isShuffling: Boolean,
@ -26,11 +25,10 @@ data class PlaybackState(
) {
companion object {
const val COLUMN_ID = "state_id"
const val COLUMN_SONG_NAME = "cur_song_name"
const val COLUMN_SONG_ALBUM_NAME = "cur_song_album"
const val COLUMN_SONG_HASH = "song"
const val COLUMN_POSITION = "position"
const val COLUMN_PARENT_NAME = "parent_name"
const val COLUMN_INDEX = "state_index"
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"

View file

@ -4,9 +4,9 @@ import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.getStringOrNull
import androidx.core.database.sqlite.transaction
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.ui.assertBackgroundThread
/**
* A SQLite database for managing the persistent playback state and queue.
@ -57,10 +57,9 @@ class PlaybackStateDatabase(context: Context) :
*/
private fun constructStateTable(command: StringBuilder): StringBuilder {
command.append("${PlaybackState.COLUMN_ID} LONG PRIMARY KEY,")
.append("${PlaybackState.COLUMN_SONG_NAME} TEXT NOT NULL,")
.append("${PlaybackState.COLUMN_SONG_ALBUM_NAME} TEXT NOT NULL,")
.append("${PlaybackState.COLUMN_SONG_HASH} INTEGER NOT NULL,")
.append("${PlaybackState.COLUMN_POSITION} LONG NOT NULL,")
.append("${PlaybackState.COLUMN_PARENT_NAME} TEXT NOT NULL,")
.append("${PlaybackState.COLUMN_PARENT_HASH} INTEGER NOT NULL,")
.append("${PlaybackState.COLUMN_INDEX} INTEGER NOT NULL,")
.append("${PlaybackState.COLUMN_MODE} INTEGER NOT NULL,")
.append("${PlaybackState.COLUMN_IS_SHUFFLING} BOOLEAN NOT NULL,")
@ -75,8 +74,8 @@ class PlaybackStateDatabase(context: Context) :
*/
private fun constructQueueTable(command: StringBuilder): StringBuilder {
command.append("${QueueItem.COLUMN_ID} LONG PRIMARY KEY,")
.append("${QueueItem.COLUMN_SONG_NAME} TEXT NOT NULL,")
.append("${QueueItem.COLUMN_ALBUM_NAME} TEXT NOT NULL,")
.append("${QueueItem.COLUMN_SONG_HASH} INTEGER NOT NULL,")
.append("${QueueItem.COLUMN_ALBUM_HASH} INTEGER NOT NULL,")
.append("${QueueItem.COLUMN_IS_USER_QUEUE} BOOLEAN NOT NULL)")
return command
@ -97,10 +96,9 @@ class PlaybackStateDatabase(context: Context) :
val stateData = ContentValues(10).apply {
put(PlaybackState.COLUMN_ID, state.id)
put(PlaybackState.COLUMN_SONG_NAME, state.songName)
put(PlaybackState.COLUMN_SONG_ALBUM_NAME, state.songAlbumName)
put(PlaybackState.COLUMN_SONG_HASH, state.songHash)
put(PlaybackState.COLUMN_POSITION, state.position)
put(PlaybackState.COLUMN_PARENT_NAME, state.parentName)
put(PlaybackState.COLUMN_PARENT_HASH, state.parentHash)
put(PlaybackState.COLUMN_INDEX, state.index)
put(PlaybackState.COLUMN_MODE, state.mode)
put(PlaybackState.COLUMN_IS_SHUFFLING, state.isShuffling)
@ -126,10 +124,9 @@ class PlaybackStateDatabase(context: Context) :
readableDatabase.queryAll(TABLE_NAME_STATE) { cursor ->
if (cursor.count == 0) return@queryAll
val songIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_SONG_NAME)
val albumIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_SONG_ALBUM_NAME)
val songIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_SONG_HASH)
val posIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_POSITION)
val parentIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_PARENT_NAME)
val parentIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_PARENT_HASH)
val indexIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_INDEX)
val modeIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_MODE)
val shuffleIndex = cursor.getColumnIndexOrThrow(PlaybackState.COLUMN_IS_SHUFFLING)
@ -141,10 +138,9 @@ class PlaybackStateDatabase(context: Context) :
cursor.moveToFirst()
state = PlaybackState(
songName = cursor.getStringOrNull(songIndex) ?: "",
songAlbumName = cursor.getStringOrNull(albumIndex) ?: "",
songHash = cursor.getInt(songIndex),
position = cursor.getLong(posIndex),
parentName = cursor.getStringOrNull(parentIndex) ?: "",
parentHash = cursor.getInt(parentIndex),
index = cursor.getInt(indexIndex),
mode = cursor.getInt(modeIndex),
isShuffling = cursor.getInt(shuffleIndex) == 1,
@ -183,8 +179,8 @@ class PlaybackStateDatabase(context: Context) :
val itemData = ContentValues(4).apply {
put(QueueItem.COLUMN_ID, item.id)
put(QueueItem.COLUMN_SONG_NAME, item.songName)
put(QueueItem.COLUMN_ALBUM_NAME, item.albumName)
put(QueueItem.COLUMN_SONG_HASH, item.songHash)
put(QueueItem.COLUMN_ALBUM_HASH, item.albumHash)
put(QueueItem.COLUMN_IS_USER_QUEUE, item.isUserQueue)
}
@ -213,15 +209,15 @@ class PlaybackStateDatabase(context: Context) :
if (cursor.count == 0) return@queryAll
val idIndex = cursor.getColumnIndexOrThrow(QueueItem.COLUMN_ID)
val songIdIndex = cursor.getColumnIndexOrThrow(QueueItem.COLUMN_SONG_NAME)
val albumIdIndex = cursor.getColumnIndexOrThrow(QueueItem.COLUMN_ALBUM_NAME)
val songIdIndex = cursor.getColumnIndexOrThrow(QueueItem.COLUMN_SONG_HASH)
val albumIdIndex = cursor.getColumnIndexOrThrow(QueueItem.COLUMN_ALBUM_HASH)
val isUserQueueIndex = cursor.getColumnIndexOrThrow(QueueItem.COLUMN_IS_USER_QUEUE)
while (cursor.moveToNext()) {
queueItems += QueueItem(
id = cursor.getLong(idIndex),
songName = cursor.getStringOrNull(songIdIndex) ?: "",
albumName = cursor.getStringOrNull(albumIdIndex) ?: "",
songHash = cursor.getInt(songIdIndex),
albumHash = cursor.getInt(albumIdIndex),
isUserQueue = cursor.getInt(isUserQueueIndex) == 1
)
}

View file

@ -3,21 +3,21 @@ package org.oxycblt.auxio.database
/**
* A database entity that stores a simplified representation of a song in a queue.
* @property id The database entity's id
* @property songName The song name for this queue item
* @property albumName The album name for this queue item, used to make searching quicker
* @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 QueueItem(
var id: Long = 0L,
val songName: String = "",
val albumName: String = "",
val songHash: Int,
val albumHash: Int,
val isUserQueue: Boolean = false
) {
companion object {
const val COLUMN_ID = "id"
const val COLUMN_SONG_NAME = "song_name"
const val COLUMN_ALBUM_NAME = "album_name"
const val COLUMN_SONG_HASH = "song"
const val COLUMN_ALBUM_HASH = "album"
const val COLUMN_IS_USER_QUEUE = "is_user_queue"
}
}

View file

@ -4,9 +4,6 @@ import android.net.Uri
// --- MUSIC MODELS ---
// TODO: Implement some kind of hash system, removing the need to redundant names but also without the volatility of id
// They need to be completely unique, however, and from whatever information I have about them on creation
/**
* The base data object for all music.
* @property id The ID that is assigned to this object
@ -20,10 +17,18 @@ sealed class BaseModel {
/**
* [BaseModel] variant that denotes that this object is a parent of other data objects, such
* as an [Album] or [Artist]
* @property displayName Name that handles the usage of [Genre.resolvedName] and the normal [BaseModel.name]
* @property hash A versatile, unique(ish) hash used for databases
* @property displayName Name that handles the usage of [Genre.resolvedName]
* and the normal [BaseModel.name]
*/
sealed class Parent : BaseModel() {
val displayName: String get() = if (this is Genre) resolvedName else name
abstract val hash: Int
val displayName: String get() = if (this is Genre) {
resolvedName
} else {
name
}
}
/**
@ -38,6 +43,7 @@ sealed class Parent : BaseModel() {
* These are not ensured to be linked due to possible quirks in the genre loading system.
* @property seconds The Song's duration in seconds
* @property formattedDuration The Song's duration as a duration string.
* @property hash A versatile, unique(ish) hash used for databases
*/
data class Song(
override val id: Long = -1,
@ -45,7 +51,7 @@ data class Song(
val fileName: String,
val albumId: Long = -1,
val track: Int = -1,
val duration: Long = 0,
val duration: Long = 0
) : BaseModel() {
private var mAlbum: Album? = null
private var mGenre: Genre? = null
@ -56,6 +62,8 @@ data class Song(
val seconds = duration / 1000
val formattedDuration = seconds.toDuration()
val hash = songHash()
fun linkAlbum(album: Album) {
if (mAlbum == null) {
mAlbum = album
@ -67,6 +75,13 @@ data class Song(
mGenre = genre
}
}
private fun songHash(): Int {
var result = name.hashCode()
result = 31 * result + track
result = 31 * result + duration.hashCode()
return result
}
}
/**
@ -94,6 +109,8 @@ data class Album(
val totalDuration: String get() =
songs.sumOf { it.seconds }.toDuration()
override val hash = albumHash()
fun linkArtist(artist: Artist) {
mArtist = artist
}
@ -104,6 +121,13 @@ data class Album(
mSongs.add(song)
}
}
private fun albumHash(): Int {
var result = name.hashCode()
result = 31 * result + artistName.hashCode()
result = 31 * result + year
return result
}
}
/**
@ -117,12 +141,6 @@ data class Artist(
override val name: String,
val albums: List<Album>
) : Parent() {
init {
albums.forEach { album ->
album.linkArtist(this)
}
}
val genre: Genre? by lazy {
// Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre.
@ -132,6 +150,14 @@ data class Artist(
val songs: List<Song> by lazy {
albums.flatMap { it.songs }
}
override val hash = name.hashCode()
init {
albums.forEach { album ->
album.linkArtist(this)
}
}
}
/**
@ -153,6 +179,8 @@ data class Genre(
val totalDuration: String get() =
songs.sumOf { it.seconds }.toDuration()
override val hash = name.hashCode()
fun linkSong(song: Song) {
mSongs.add(song)
song.linkGenre(this)

View file

@ -13,8 +13,12 @@ import org.oxycblt.auxio.logD
/**
* Class that loads/constructs [Genre]s, [Artist]s, [Album]s, and [Song] objects from the filesystem
* TODO: Use album artist instead of artist tag.
* @author OxygenCobalt
*
* FIXME: Here's a catalog of problems that I already know about with this abomination
* - Does not support the album artist tag
* - All loading is done at startup [Not efficent for large libraries, would require massive arch retooling to fix]
* - Genre system is a bottleneck [Nothing I can do about it, MediaStore is garbage]
*/
class MusicLoader(private val context: Context) {
var genres = mutableListOf<Genre>()
@ -94,9 +98,9 @@ class MusicLoader(private val context: Context) {
Albums._ID, // 0
Albums.ALBUM, // 1
Albums.ARTIST, // 2
Albums.FIRST_YEAR, // 3
Albums.FIRST_YEAR, // 4
),
null, null,
"", null,
Albums.DEFAULT_SORT_ORDER
)
@ -106,13 +110,13 @@ class MusicLoader(private val context: Context) {
albumCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Albums._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Albums.ALBUM)
val artistIdIndex = cursor.getColumnIndexOrThrow(Albums.ARTIST)
val artistNameIndex = cursor.getColumnIndexOrThrow(Albums.ARTIST)
val yearIndex = cursor.getColumnIndexOrThrow(Albums.FIRST_YEAR)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val name = cursor.getString(nameIndex) ?: albumPlaceholder
var artistName = cursor.getString(artistIdIndex) ?: artistPlaceholder
var artistName = cursor.getString(artistNameIndex) ?: artistPlaceholder
val year = cursor.getInt(yearIndex)
val coverUri = id.toAlbumArtURI()
@ -144,7 +148,7 @@ class MusicLoader(private val context: Context) {
Media.TITLE, // 2
Media.ALBUM_ID, // 3
Media.TRACK, // 4
Media.DURATION // 5
Media.DURATION, // 5
),
selector, args,
Media.DEFAULT_SORT_ORDER

View file

@ -75,20 +75,14 @@ class MusicStore private constructor() {
}
/**
* Find a song from this instance in a safe manner.
* Using a normal search of the songs list runs the risk of getting the *wrong* song with
* the same name, so the album name is also used to fix the above problem.
* FIXME: Artist names are more unique than album names, use those
* @param name The name of the song
* @param albumName The name of the song's album.
* @return The song requested, null if there isnt one.
* Find a song in a faster manner using a hash for its album as well.
*/
fun findSong(name: String, albumName: String): Song? {
return albums.find { it.name == albumName }?.songs?.find { it.name == name }
fun findSongFast(songHash: Int, albumHash: Int): Song? {
return albums.find { it.hash == albumHash }?.songs?.find { it.hash == songHash }
}
/**
* Find a song for a [uri], this is similar to [findSong], but with some kind of content uri.
* Find a song for a [uri], this is similar to [findSongFast], but with some kind of content uri.
* @return The corresponding [Song] for this [uri], null if there isnt one.
*/
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {

View file

@ -27,7 +27,6 @@ import org.oxycblt.auxio.settings.SettingsManager
* All access should be done with [PlaybackStateManager.getInstance].
*
* TODO: Queues should reflect sort mode
* TODO: Update loop mode to actually make sense [#13]
* @author OxygenCobalt
*/
class PlaybackStateManager private constructor() {
@ -605,10 +604,9 @@ class PlaybackStateManager private constructor() {
*/
private fun packToPlaybackState(): PlaybackState {
return PlaybackState(
songName = mSong?.name ?: "",
songAlbumName = mSong?.album?.name ?: "",
songHash = mSong?.hash ?: Int.MIN_VALUE,
position = mPosition,
parentName = mParent?.name ?: "",
parentHash = mParent?.hash ?: Int.MIN_VALUE,
index = mIndex,
mode = mMode.toInt(),
isShuffling = mIsShuffling,
@ -625,11 +623,11 @@ class PlaybackStateManager private constructor() {
// Do queue setup first
mMode = PlaybackMode.fromInt(playbackState.mode) ?: PlaybackMode.ALL_SONGS
mParent = findParent(playbackState.parentName, mMode)
mParent = findParent(playbackState.parentHash, mMode)
mIndex = playbackState.index
// Then set up the current state
mSong = musicStore.findSong(playbackState.songName, playbackState.songAlbumName)
mSong = musicStore.songs.find { it.hash == playbackState.songHash }
mLoopMode = LoopMode.fromInt(playbackState.loopMode) ?: LoopMode.NONE
mIsShuffling = playbackState.isShuffling
mIsInUserQueue = playbackState.inUserQueue
@ -647,12 +645,12 @@ class PlaybackStateManager private constructor() {
var queueItemId = 0L
mUserQueue.forEach { song ->
unified.add(QueueItem(queueItemId, song.name, song.album.name, true))
unified.add(QueueItem(queueItemId, song.hash, song.album.hash, true))
queueItemId++
}
mQueue.forEach { song ->
unified.add(QueueItem(queueItemId, song.name, song.album.name, false))
unified.add(QueueItem(queueItemId, song.hash, song.album.hash, false))
queueItemId++
}
@ -664,8 +662,8 @@ class PlaybackStateManager private constructor() {
* @param queueItems The list of [QueueItem]s to unpack.
*/
private fun unpackQueue(queueItems: List<QueueItem>) {
queueItems.forEach { item ->
musicStore.findSong(item.songName, item.albumName)?.let { song ->
for (item in queueItems) {
musicStore.findSongFast(item.songHash, item.albumHash)?.let { song ->
if (item.isUserQueue) {
mUserQueue.add(song)
} else {
@ -689,15 +687,14 @@ class PlaybackStateManager private constructor() {
}
/**
* Get a [Parent] from music store given a [name] and playback [mode].
* Get a [Parent] from music store given a [hash] and PlaybackMode [mode].
*/
private fun findParent(name: String, mode: PlaybackMode): Parent? {
private fun findParent(hash: Int, mode: PlaybackMode): Parent? {
return when (mode) {
PlaybackMode.IN_GENRE -> musicStore.genres.find { it.name == name }
PlaybackMode.IN_ARTIST -> musicStore.artists.find { it.name == name }
PlaybackMode.IN_ALBUM -> musicStore.albums.find { it.name == name }
else -> null
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
}
}

View file

@ -18,6 +18,7 @@ import org.oxycblt.auxio.settings.blacklist.BlacklistDialog
import org.oxycblt.auxio.settings.ui.IntListPrefDialog
import org.oxycblt.auxio.settings.ui.IntListPreference
import org.oxycblt.auxio.ui.Accent
import org.oxycblt.auxio.ui.showToast
/**
* The actual fragment containing the settings menu. Inherits [PreferenceFragmentCompat].
@ -129,7 +130,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
SettingsManager.KEY_SAVE_STATE -> {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
playbackModel.savePlaybackState(requireContext()) {
requireContext().getString(R.string.label_state_saved)
requireContext().showToast(R.string.label_state_saved)
}
true

View file

@ -9,6 +9,7 @@ import android.graphics.Point
import android.graphics.drawable.AnimatedVectorDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Looper
import android.util.DisplayMetrics
import android.util.TypedValue
import android.view.LayoutInflater
@ -138,6 +139,24 @@ fun Context.showToast(@StringRes str: Int) {
Toast.makeText(applicationContext, getString(str), Toast.LENGTH_SHORT).show()
}
/**
* Assert that we are on a background thread.
*/
fun assertBackgroundThread() {
check(Looper.myLooper() != Looper.getMainLooper()) {
"This operation must be ran on a background thread."
}
}
/**
* Assert that we are on a foreground thread.
*/
fun assertMainThread() {
check(Looper.myLooper() == Looper.getMainLooper()) {
"This operation must be ran on the main thread"
}
}
// --- CONFIGURATION ---
/**

View file

@ -1,6 +1,5 @@
package org.oxycblt.auxio.ui
import android.os.Looper
import android.view.LayoutInflater
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
@ -42,9 +41,7 @@ class MemberBinder<T : ViewDataBinding>(
}
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
check(Looper.myLooper() == Looper.getMainLooper()) {
"View can only be accessed on the main thread."
}
assertMainThread()
val binding = fragmentBinding