music: rework callback configuration

Rework the asynchronous aspects of MusicStore to rely on a more
conventional callback system.

In preparation for automatic rescanning, it would be more elegant
for music consumers to use a callback, so that updates can be
registered easier than if a co-routine continued to be used.
Convert MusicStore to this new model and rework the accesses of
MusicStore in-app to line up with this new system.
This commit is contained in:
OxygenCobalt 2022-04-07 20:18:25 -06:00
parent 3a19d822ce
commit 180faa6f50
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 218 additions and 257 deletions

View file

@ -38,7 +38,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logD
/** /**
* A wrapper around the home fragment that shows the playback fragment and controls the more * A wrapper around the home fragment that shows the playback fragment and controls the more
@ -122,32 +122,30 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// Error, show the error to the user // Error, show the error to the user
is MusicStore.Response.Err -> { is MusicStore.Response.Err -> {
logW("Received Error") logD("Received Response.Err")
Snackbar.make(binding.root, R.string.err_load_failed, Snackbar.LENGTH_INDEFINITE)
val errorRes = .apply {
when (response.kind) { setAction(R.string.lbl_retry) { musicModel.reloadMusic(context) }
MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music show()
MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms
MusicStore.ErrorKind.FAILED -> R.string.err_load_failed
} }
}
val snackbar = is MusicStore.Response.NoMusic -> {
Snackbar.make(binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE) logD("Received Response.NoMusic")
Snackbar.make(binding.root, R.string.err_no_music, Snackbar.LENGTH_INDEFINITE)
when (response.kind) { .apply {
MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> { setAction(R.string.lbl_retry) { musicModel.reloadMusic(context) }
snackbar.setAction(R.string.lbl_retry) { show()
musicModel.reloadMusic(requireContext())
}
} }
MusicStore.ErrorKind.NO_PERMS -> { }
snackbar.setAction(R.string.lbl_grant) { is MusicStore.Response.NoPerms -> {
logD("Received Response.NoPerms")
Snackbar.make(binding.root, R.string.err_no_perms, Snackbar.LENGTH_INDEFINITE)
.apply {
setAction(R.string.lbl_grant) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
} }
show()
} }
}
snackbar.show()
} }
null -> {} null -> {}
} }

View file

@ -31,6 +31,7 @@ import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* ViewModel that stores data for the [DetailFragment]s. This includes: * ViewModel that stores data for the [DetailFragment]s. This includes:
@ -40,6 +41,7 @@ import org.oxycblt.auxio.util.logD
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class DetailViewModel : ViewModel() { class DetailViewModel : ViewModel() {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private val mCurrentAlbum = MutableLiveData<Album?>() private val mCurrentAlbum = MutableLiveData<Album?>()
@ -87,9 +89,9 @@ class DetailViewModel : ViewModel() {
fun setAlbumId(id: Long) { fun setAlbumId(id: Long) {
if (mCurrentAlbum.value?.id == id) return if (mCurrentAlbum.value?.id == id) return
val musicStore = MusicStore.requireInstance() val library = unlikelyToBeNull(musicStore.library)
val album = val album =
requireNotNull(musicStore.albums.find { it.id == id }) { "Invalid album id provided " } requireNotNull(library.albums.find { it.id == id }) { "Invalid album id provided " }
mCurrentAlbum.value = album mCurrentAlbum.value = album
refreshAlbumData(album) refreshAlbumData(album)
@ -97,16 +99,18 @@ class DetailViewModel : ViewModel() {
fun setArtistId(id: Long) { fun setArtistId(id: Long) {
if (mCurrentArtist.value?.id == id) return if (mCurrentArtist.value?.id == id) return
val musicStore = MusicStore.requireInstance() val library = unlikelyToBeNull(musicStore.library)
val artist = requireNotNull(musicStore.artists.find { it.id == id }) {} val artist =
requireNotNull(library.artists.find { it.id == id }) { "Invalid artist id provided" }
mCurrentArtist.value = artist mCurrentArtist.value = artist
refreshArtistData(artist) refreshArtistData(artist)
} }
fun setGenreId(id: Long) { fun setGenreId(id: Long) {
if (mCurrentGenre.value?.id == id) return if (mCurrentGenre.value?.id == id) return
val musicStore = MusicStore.requireInstance() val library = unlikelyToBeNull(musicStore.library)
val genre = requireNotNull(musicStore.genres.find { it.id == id }) val genre =
requireNotNull(library.genres.find { it.id == id }) { "Invalid genre id provided" }
mCurrentGenre.value = genre mCurrentGenre.value = genre
refreshGenreData(genre) refreshGenreData(genre)
} }

View file

@ -20,8 +20,6 @@ package org.oxycblt.auxio.home
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -38,7 +36,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state. * The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class HomeViewModel : ViewModel(), SettingsManager.Callback { class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private val mSongs = MutableLiveData(listOf<Song>()) private val mSongs = MutableLiveData(listOf<Song>())
@ -78,15 +77,8 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
val fastScrolling: LiveData<Boolean> = mFastScrolling val fastScrolling: LiveData<Boolean> = mFastScrolling
init { init {
musicStore.addCallback(this)
settingsManager.addCallback(this) settingsManager.addCallback(this)
viewModelScope.launch {
val musicStore = MusicStore.awaitInstance()
mSongs.value = settingsManager.libSongSort.songs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.albums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.artists(musicStore.artists)
mGenres.value = settingsManager.libGenreSort.genres(musicStore.genres)
}
} }
/** Update the current tab based off of the new ViewPager position. */ /** Update the current tab based off of the new ViewPager position. */
@ -142,6 +134,16 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
// --- OVERRIDES --- // --- OVERRIDES ---
override fun onMusicUpdate(response: MusicStore.Response) {
if (response is MusicStore.Response.Ok) {
val library = response.library
mSongs.value = settingsManager.libSongSort.songs(library.songs)
mAlbums.value = settingsManager.libAlbumSort.albums(library.albums)
mArtists.value = settingsManager.libArtistSort.artists(library.artists)
mGenres.value = settingsManager.libGenreSort.genres(library.genres)
}
}
override fun onLibTabsUpdate(libTabs: Array<Tab>) { override fun onLibTabsUpdate(libTabs: Array<Tab>) {
tabs = visibleTabs tabs = visibleTabs
mRecreateTabs.value = true mRecreateTabs.value = true
@ -149,6 +151,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
musicStore.addCallback(this)
settingsManager.removeCallback(this) settingsManager.removeCallback(this)
} }
} }

View file

@ -89,15 +89,24 @@ import org.oxycblt.auxio.util.logD
* *
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MusicLoader { object Indexer {
data class Library( /**
val genres: List<Genre>, * The album_artist MediaStore field has existed since at least API 21, but until API 30 it was
val artists: List<Artist>, * a proprietary extension for Google Play Music and was not documented. Since this field
val albums: List<Album>, * probably works on all versions Auxio supports, we suppress the warning about using a
val songs: List<Song> * possibly-unsupported constant.
) */
@Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
fun load(context: Context): Library? { /**
* Gets a content resolver in a way that does not mangle metadata on certain OEM skins. See
* https://github.com/OxygenCobalt/Auxio/issues/50 for more info.
*/
private val Context.contentResolverSafe: ContentResolver
get() = applicationContext.contentResolver
fun run(context: Context): MusicStore.Library? {
val songs = loadSongs(context) val songs = loadSongs(context)
if (songs.isEmpty()) return null if (songs.isEmpty()) return null
@ -118,16 +127,9 @@ class MusicLoader {
} }
} }
return Library(genres, artists, albums, songs) return MusicStore.Library(genres, artists, albums, songs)
} }
/**
* Gets a content resolver in a way that does not mangle metadata on certain OEM skins. See
* https://github.com/OxygenCobalt/Auxio/issues/50 for more info.
*/
private val Context.contentResolverSafe: ContentResolver
get() = applicationContext.contentResolver
/** /**
* Does the initial query over the song database, including excluded directory checks. The songs * Does the initial query over the song database, including excluded directory checks. The songs
* returned by this function are **not** well-formed. The companion [buildAlbums], * returned by this function are **not** well-formed. The companion [buildAlbums],
@ -404,15 +406,4 @@ class MusicLoader {
return genreSongs.ifEmpty { null } return genreSongs.ifEmpty { null }
} }
companion object {
/**
* The album_artist MediaStore field has existed since at least API 21, but until API 30 it
* was a proprietary extension for Google Play Music and was not documented. Since this
* field probably works on all versions Auxio supports, we suppress the warning about using
* a possibly-unsupported constant.
*/
@Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
}
} }

View file

@ -29,9 +29,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
// --- MUSIC MODELS --- // --- MUSIC MODELS ---
/** /** [Item] variant that represents a music item. */
* [Item] variant that represents a music item.
*/
sealed class Music : Item() { sealed class Music : Item() {
/** The raw name of this item. Null if unknown. */ /** The raw name of this item. Null if unknown. */
abstract val rawName: String? abstract val rawName: String?
@ -116,8 +114,8 @@ data class Song(
get() = internalMediaStoreArtistName ?: album.artist.rawName get() = internalMediaStoreArtistName ?: album.artist.rawName
/** /**
* Resolve the artist name for this song in particular. First uses the artist tag, and * Resolve the artist name for this song in particular. First uses the artist tag, and then
* then falls back to the album artist tag (i.e parent artist name) * falls back to the album artist tag (i.e parent artist name)
*/ */
fun resolveIndividualArtistName(context: Context) = fun resolveIndividualArtistName(context: Context) =
internalMediaStoreArtistName ?: album.artist.resolveName(context) internalMediaStoreArtistName ?: album.artist.resolveName(context)

View file

@ -38,76 +38,95 @@ import org.oxycblt.auxio.util.logE
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MusicStore private constructor() { class MusicStore private constructor() {
private var mGenres = listOf<Genre>() private var response: Response? = null
val genres: List<Genre> val library: Library?
get() = mGenres get() =
response?.let { currentResponse ->
if (currentResponse is Response.Ok) {
currentResponse.library
} else {
null
}
}
private var mArtists = listOf<Artist>() private val callbacks = mutableListOf<Callback>()
val artists: List<Artist>
get() = mArtists
private var mAlbums = listOf<Album>() fun addCallback(callback: Callback) {
val albums: List<Album> callbacks.add(callback)
get() = mAlbums }
private var mSongs = listOf<Song>() fun removeCallback(callback: Callback) {
val songs: List<Song> callbacks.remove(callback)
get() = mSongs }
/** Load/Sort the entire music library. Should always be ran on a coroutine. */ /** Load/Sort the entire music library. Should always be ran on a coroutine. */
private fun load(context: Context): Response { suspend fun index(context: Context): Response {
logD("Starting initial music load") logD("Starting initial music load")
val newResponse = withContext(Dispatchers.IO) { indexImpl(context) }.also { response = it }
for (callback in callbacks) {
callback.onMusicUpdate(newResponse)
}
return newResponse
}
private fun indexImpl(context: Context): Response {
val notGranted = val notGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_DENIED PackageManager.PERMISSION_DENIED
if (notGranted) { if (notGranted) {
return Response.Err(ErrorKind.NO_PERMS) return Response.NoPerms
} }
try { val response =
val start = System.currentTimeMillis() try {
val start = System.currentTimeMillis()
val library = Indexer.run(context)
if (library != null) {
logD(
"Music load completed successfully in ${System.currentTimeMillis() - start}ms")
Response.Ok(library)
} else {
logE("No music found")
Response.NoMusic
}
} catch (e: Exception) {
logE("Music loading failed.")
logE(e.stackTraceToString())
Response.Err(e)
}
val loader = MusicLoader() return response
val library = loader.load(context) ?: return Response.Err(ErrorKind.NO_MUSIC)
mSongs = library.songs
mAlbums = library.albums
mArtists = library.artists
mGenres = library.genres
logD("Music load completed successfully in ${System.currentTimeMillis() - start}ms")
} catch (e: Exception) {
logE("Music loading failed.")
logE(e.stackTraceToString())
return Response.Err(ErrorKind.FAILED)
}
return Response.Ok(this)
} }
/** Find a song in a faster manner using an ID for its album as well. */ data class Library(
fun findSongFast(songId: Long, albumId: Long): Song? { val genres: List<Genre>,
return albums.find { it.id == albumId }?.songs?.find { it.id == songId } val artists: List<Artist>,
} val albums: List<Album>,
val songs: List<Song>
/** ) {
* Find a song for a [uri], this is similar to [findSongFast], but with some kind of content /** Find a song in a faster manner using an ID for its album as well. */
* uri. fun findSongFast(songId: Long, albumId: Long): Song? {
* @return The corresponding [Song] for this [uri], null if there isn't one. return albums.find { it.id == albumId }?.songs?.find { it.id == songId }
*/
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor
->
cursor.moveToFirst()
val fileName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
return songs.find { it.fileName == fileName }
} }
return null /**
* 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 isn't one.
*/
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
cursor ->
cursor.moveToFirst()
val fileName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
return songs.find { it.fileName == fileName }
}
return null
}
} }
/** /**
@ -117,93 +136,31 @@ class MusicStore private constructor() {
* TODO: Add the exception to the "FAILED" ErrorKind * TODO: Add the exception to the "FAILED" ErrorKind
*/ */
sealed class Response { sealed class Response {
class Ok(val musicStore: MusicStore) : Response() class Ok(val library: Library) : Response()
class Err(val kind: ErrorKind) : Response() class Err(throwable: Throwable) : Response()
object NoMusic : Response()
object NoPerms : Response()
} }
enum class ErrorKind { interface Callback {
NO_PERMS, fun onMusicUpdate(response: Response)
NO_MUSIC,
FAILED
} }
companion object { companion object {
@Volatile private var RESPONSE: Response? = null @Volatile private var INSTANCE: MusicStore? = null
/** fun getInstance(): MusicStore {
* Initialize the loading process for this instance. This must be ran on a background val currentInstance = INSTANCE
* thread. If the instance has already been loaded successfully, then it will be returned
* immediately.
*/
suspend fun initInstance(context: Context): Response {
val currentInstance = RESPONSE
if (currentInstance is Response.Ok) { if (currentInstance != null) {
return currentInstance return currentInstance
} }
val response = synchronized(this) {
withContext(Dispatchers.IO) { val newInstance = MusicStore()
val response = MusicStore().load(context) INSTANCE = newInstance
synchronized(this) { RESPONSE = response } return newInstance
response
}
return response
}
/**
* Await the successful creation of a [MusicStore] instance. The co-routine calling this
* will block until the successful creation of a [MusicStore], in which it will then be
* returned.
*/
suspend fun awaitInstance() =
withContext(Dispatchers.Default) {
// We have to do a withContext call so we don't block the JVM thread
val musicStore: MusicStore
while (true) {
val response = RESPONSE
if (response is Response.Ok) {
musicStore = response.musicStore
break
}
}
musicStore
} }
/**
* Maybe get a MusicStore instance. This is useful if you are running code while the loading
* process may still be going on.
*
* @return null if the music store instance is still loading or if the loading process has
* encountered an error. An instance is returned otherwise.
*/
fun maybeGetInstance(): MusicStore? {
val currentInstance = RESPONSE
return if (currentInstance is Response.Ok) {
currentInstance.musicStore
} else {
null
}
}
/**
* Require a MusicStore instance. This function is dangerous and should only be used if it's
* guaranteed that the caller's code will only be called after the initial loading process.
*/
fun requireInstance(): MusicStore {
return requireNotNull(maybeGetInstance()) {
"Required MusicStore instance was not available"
}
}
/** Check if this instance has successfully loaded or not. */
fun loaded(): Boolean {
return maybeGetInstance() != null
} }
} }
} }

View file

@ -25,12 +25,18 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
class MusicViewModel : ViewModel() { class MusicViewModel : ViewModel(), MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null) private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null)
val loaderResponse: LiveData<MusicStore.Response?> = mLoaderResponse val loaderResponse: LiveData<MusicStore.Response?> = mLoaderResponse
private var isBusy = false private var isBusy = false
init {
musicStore.addCallback(this)
}
/** /**
* Initiate the loading process. This is done here since HomeFragment will be the first fragment * Initiate the loading process. This is done here since HomeFragment will be the first fragment
* navigated to and because SnackBars will have the best UX here. * navigated to and because SnackBars will have the best UX here.
@ -45,7 +51,7 @@ class MusicViewModel : ViewModel() {
mLoaderResponse.value = null mLoaderResponse.value = null
viewModelScope.launch { viewModelScope.launch {
val result = MusicStore.initInstance(context) val result = musicStore.index(context)
mLoaderResponse.value = result mLoaderResponse.value = result
isBusy = false isBusy = false
} }
@ -56,4 +62,13 @@ class MusicViewModel : ViewModel() {
mLoaderResponse.value = null mLoaderResponse.value = null
loadMusic(context) loadMusic(context)
} }
override fun onMusicUpdate(response: MusicStore.Response) {
mLoaderResponse.value = response
}
override fun onCleared() {
super.onCleared()
musicStore.removeCallback(this)
}
} }

View file

@ -51,6 +51,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* - DO NOT REWRITE IT! THAT'S BAD AND WILL PROBABLY RE-INTRODUCE A TON OF BUGS. * - DO NOT REWRITE IT! THAT'S BAD AND WILL PROBABLY RE-INTRODUCE A TON OF BUGS.
*/ */
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val playbackManager = PlaybackStateManager.getInstance()
// Playback // Playback
private val mSong = MutableLiveData<Song?>() private val mSong = MutableLiveData<Song?>()
private val mParent = MutableLiveData<MusicParent?>() private val mParent = MutableLiveData<MusicParent?>()
@ -94,9 +98,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
val playbackMode: LiveData<PlaybackMode> val playbackMode: LiveData<PlaybackMode>
get() = mMode get() = mMode
private val playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance()
init { init {
playbackManager.addCallback(this) playbackManager.addCallback(this)
@ -166,7 +167,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/ */
fun playWithUri(uri: Uri, context: Context) { fun playWithUri(uri: Uri, context: Context) {
// Check if everything is already running to run the URI play // Check if everything is already running to run the URI play
if (playbackManager.isRestored && MusicStore.loaded()) { if (playbackManager.isRestored && musicStore.library != null) {
playWithUriInternal(uri, context) playWithUriInternal(uri, context)
} else { } else {
logD("Cant play this URI right now, waiting") logD("Cant play this URI right now, waiting")
@ -178,9 +179,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
/** Play with a file URI. This is called after [playWithUri] once its deemed safe to do so. */ /** Play with a file URI. This is called after [playWithUri] once its deemed safe to do so. */
private fun playWithUriInternal(uri: Uri, context: Context) { private fun playWithUriInternal(uri: Uri, context: Context) {
logD("Playing with uri $uri") logD("Playing with uri $uri")
val library = musicStore.library ?: return
val musicStore = MusicStore.maybeGetInstance() ?: return library.findSongForUri(uri, context.contentResolver)?.let { song -> playSong(song) }
musicStore.findSongForUri(uri, context.contentResolver)?.let { song -> playSong(song) }
} }
/** Shuffle all songs */ /** Shuffle all songs */

View file

@ -105,7 +105,7 @@ class PlaybackStateDatabase(context: Context) :
* @param musicStore Required to transform database songs/parents into actual instances * @param musicStore Required to transform database songs/parents into actual instances
* @return The stored [SavedState], null if there isn't one. * @return The stored [SavedState], null if there isn't one.
*/ */
fun readState(musicStore: MusicStore): SavedState? { fun readState(library: MusicStore.Library): SavedState? {
requireBackgroundThread() requireBackgroundThread()
var state: SavedState? = null var state: SavedState? = null
@ -124,16 +124,16 @@ class PlaybackStateDatabase(context: Context) :
cursor.moveToFirst() cursor.moveToFirst()
val song = val song =
cursor.getLongOrNull(songIndex)?.let { id -> musicStore.songs.find { it.id == id } } cursor.getLongOrNull(songIndex)?.let { id -> library.songs.find { it.id == id } }
val mode = PlaybackMode.fromInt(cursor.getInt(modeIndex)) ?: PlaybackMode.ALL_SONGS val mode = PlaybackMode.fromInt(cursor.getInt(modeIndex)) ?: PlaybackMode.ALL_SONGS
val parent = val parent =
cursor.getLongOrNull(parentIndex)?.let { id -> cursor.getLongOrNull(parentIndex)?.let { id ->
when (mode) { when (mode) {
PlaybackMode.IN_GENRE -> musicStore.genres.find { it.id == id } PlaybackMode.IN_GENRE -> library.genres.find { it.id == id }
PlaybackMode.IN_ARTIST -> musicStore.artists.find { it.id == id } PlaybackMode.IN_ARTIST -> library.artists.find { it.id == id }
PlaybackMode.IN_ALBUM -> musicStore.albums.find { it.id == id } PlaybackMode.IN_ALBUM -> library.albums.find { it.id == id }
PlaybackMode.ALL_SONGS -> null PlaybackMode.ALL_SONGS -> null
} }
} }
@ -186,7 +186,7 @@ class PlaybackStateDatabase(context: Context) :
* Read a list of queue items from this database. * Read a list of queue items from this database.
* @param musicStore Required to transform database songs into actual song instances * @param musicStore Required to transform database songs into actual song instances
*/ */
fun readQueue(musicStore: MusicStore): MutableList<Song> { fun readQueue(library: MusicStore.Library): MutableList<Song> {
requireBackgroundThread() requireBackgroundThread()
val queue = mutableListOf<Song>() val queue = mutableListOf<Song>()
@ -198,8 +198,10 @@ class PlaybackStateDatabase(context: Context) :
val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_HASH) val albumIndex = cursor.getColumnIndexOrThrow(QueueColumns.ALBUM_HASH)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
musicStore.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex)) library.findSongFast(cursor.getLong(songIndex), cursor.getLong(albumIndex))?.let {
?.let { song -> queue.add(song) } song ->
queue.add(song)
}
} }
} }

View file

@ -43,6 +43,9 @@ import org.oxycblt.auxio.util.logE
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class PlaybackStateManager private constructor() { class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
// Playback // Playback
private var mSong: Song? = null private var mSong: Song? = null
set(value) { set(value) {
@ -124,8 +127,6 @@ class PlaybackStateManager private constructor() {
val hasPlayed: Boolean val hasPlayed: Boolean
get() = mHasPlayed get() = mHasPlayed
private val settingsManager = SettingsManager.getInstance()
// --- CALLBACKS --- // --- CALLBACKS ---
private val callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()
@ -154,8 +155,7 @@ class PlaybackStateManager private constructor() {
when (mode) { when (mode) {
PlaybackMode.ALL_SONGS -> { PlaybackMode.ALL_SONGS -> {
val musicStore = MusicStore.maybeGetInstance() ?: return val musicStore = musicStore.library ?: return
mParent = null mParent = null
mQueue = musicStore.songs.toMutableList() mQueue = musicStore.songs.toMutableList()
} }
@ -211,10 +211,11 @@ class PlaybackStateManager private constructor() {
/** Shuffle all songs. */ /** Shuffle all songs. */
fun shuffleAll() { fun shuffleAll() {
val musicStore = MusicStore.maybeGetInstance() ?: return logD("RETARD. ${musicStore.library}")
val library = musicStore.library ?: return
mPlaybackMode = PlaybackMode.ALL_SONGS mPlaybackMode = PlaybackMode.ALL_SONGS
mQueue = musicStore.songs.toMutableList() mQueue = library.songs.toMutableList()
mParent = null mParent = null
setShuffling(true, keepSong = false) setShuffling(true, keepSong = false)
@ -370,13 +371,13 @@ class PlaybackStateManager private constructor() {
* @param keepSong Whether the current song should be kept as the queue is un-shuffled * @param keepSong Whether the current song should be kept as the queue is un-shuffled
*/ */
private fun resetShuffle(keepSong: Boolean) { private fun resetShuffle(keepSong: Boolean) {
val musicStore = MusicStore.maybeGetInstance() ?: return val library = musicStore.library ?: return
val lastSong = mSong val lastSong = mSong
mQueue = mQueue =
when (mPlaybackMode) { when (mPlaybackMode) {
PlaybackMode.ALL_SONGS -> PlaybackMode.ALL_SONGS ->
settingsManager.libSongSort.songs(musicStore.songs).toMutableList() settingsManager.libSongSort.songs(library.songs).toMutableList()
PlaybackMode.IN_ALBUM -> PlaybackMode.IN_ALBUM ->
settingsManager.detailAlbumSort.album(mParent as Album).toMutableList() settingsManager.detailAlbumSort.album(mParent as Album).toMutableList()
PlaybackMode.IN_ARTIST -> PlaybackMode.IN_ARTIST ->
@ -496,7 +497,7 @@ class PlaybackStateManager private constructor() {
suspend fun restoreFromDatabase(context: Context) { suspend fun restoreFromDatabase(context: Context) {
logD("Getting state from DB") logD("Getting state from DB")
val musicStore = MusicStore.maybeGetInstance() ?: return val library = musicStore.library ?: return
val start: Long val start: Long
val playbackState: PlaybackStateDatabase.SavedState? val playbackState: PlaybackStateDatabase.SavedState?
val queue: MutableList<Song> val queue: MutableList<Song>
@ -504,8 +505,8 @@ class PlaybackStateManager private constructor() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
start = System.currentTimeMillis() start = System.currentTimeMillis()
val database = PlaybackStateDatabase.getInstance(context) val database = PlaybackStateDatabase.getInstance(context)
playbackState = database.readState(musicStore) playbackState = database.readState(library)
queue = database.readQueue(musicStore) queue = database.readQueue(library)
} }
// Get off the IO coroutine since it will cause LiveData updates to throw an exception // Get off the IO coroutine since it will cause LiveData updates to throw an exception

View file

@ -76,6 +76,8 @@ import org.oxycblt.auxio.widgets.WidgetProvider
* *
* TODO: Move all external exposal from passing around PlaybackStateManager to passing around the * TODO: Move all external exposal from passing around PlaybackStateManager to passing around the
* MediaMetadata instance. Generally makes it easier to encapsulate this class. * MediaMetadata instance. Generally makes it easier to encapsulate this class.
*
* TODO: Move restore and file opening to service
*/ */
class PlaybackService : class PlaybackService :
Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback { Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {

View file

@ -115,11 +115,6 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>(), MenuItemLis
musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse) musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse)
} }
override fun onResume() {
super.onResume()
searchModel.setNavigating(false)
}
override fun onDestroyBinding(binding: FragmentSearchBinding) { override fun onDestroyBinding(binding: FragmentSearchBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.searchRecycler.adapter = null binding.searchRecycler.adapter = null

View file

@ -40,8 +40,10 @@ import org.oxycblt.auxio.util.logD
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SearchViewModel : ViewModel() { class SearchViewModel : ViewModel() {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val mSearchResults = MutableLiveData(listOf<Item>()) private val mSearchResults = MutableLiveData(listOf<Item>())
private var mIsNavigating = false
private var mFilterMode: DisplayMode? = null private var mFilterMode: DisplayMode? = null
private var mLastQuery: String? = null private var mLastQuery: String? = null
@ -51,8 +53,6 @@ class SearchViewModel : ViewModel() {
val filterMode: DisplayMode? val filterMode: DisplayMode?
get() = mFilterMode get() = mFilterMode
private val settingsManager = SettingsManager.getInstance()
init { init {
mFilterMode = settingsManager.searchFilterMode mFilterMode = settingsManager.searchFilterMode
} }
@ -61,10 +61,10 @@ class SearchViewModel : ViewModel() {
* Use [query] to perform a search of the music library. Will push results to [searchResults]. * Use [query] to perform a search of the music library. Will push results to [searchResults].
*/ */
fun search(context: Context, query: String?) { fun search(context: Context, query: String?) {
val musicStore = MusicStore.maybeGetInstance()
mLastQuery = query mLastQuery = query
if (query.isNullOrEmpty() || musicStore == null) { val library = musicStore.library
if (query.isNullOrEmpty() || library == null) {
logD("No music/query, ignoring search") logD("No music/query, ignoring search")
mSearchResults.value = listOf() mSearchResults.value = listOf()
return return
@ -80,28 +80,28 @@ class SearchViewModel : ViewModel() {
// Note: a filter mode of null means to not filter at all. // Note: a filter mode of null means to not filter at all.
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) {
musicStore.artists.filterByOrNull(context, query)?.let { artists -> library.artists.filterByOrNull(context, query)?.let { artists ->
results.add(Header(-1, R.string.lbl_artists)) results.add(Header(-1, R.string.lbl_artists))
results.addAll(sort.artists(artists)) results.addAll(sort.artists(artists))
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) {
musicStore.albums.filterByOrNull(context, query)?.let { albums -> library.albums.filterByOrNull(context, query)?.let { albums ->
results.add(Header(-2, R.string.lbl_albums)) results.add(Header(-2, R.string.lbl_albums))
results.addAll(sort.albums(albums)) results.addAll(sort.albums(albums))
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) {
musicStore.genres.filterByOrNull(context, query)?.let { genres -> library.genres.filterByOrNull(context, query)?.let { genres ->
results.add(Header(-3, R.string.lbl_genres)) results.add(Header(-3, R.string.lbl_genres))
results.addAll(sort.genres(genres)) results.addAll(sort.genres(genres))
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) {
musicStore.songs.filterByOrNull(context, query)?.let { songs -> library.songs.filterByOrNull(context, query)?.let { songs ->
results.add(Header(-4, R.string.lbl_songs)) results.add(Header(-4, R.string.lbl_songs))
results.addAll(sort.songs(songs)) results.addAll(sort.songs(songs))
} }
@ -181,9 +181,4 @@ class SearchViewModel : ViewModel() {
return sb.toString() return sb.toString()
} }
/** Update the current navigation status to [isNavigating] */
fun setNavigating(isNavigating: Boolean) {
mIsNavigating = isNavigating
}
} }

View file

@ -18,7 +18,7 @@
<org.oxycblt.auxio.coil.StyledImageView <org.oxycblt.auxio.coil.StyledImageView
android:id="@+id/playback_cover" android:id="@+id/playback_cover"
style="@style/Widget.Auxio.Image.Full" style="@style/Widget.Auxio.Image.Full"
android:layout_margin="@dimen/spacing_large" android:layout_margin="@dimen/spacing_mid_large"
app:layout_constraintBottom_toTopOf="@+id/playback_song" app:layout_constraintBottom_toTopOf="@+id/playback_song"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -30,8 +30,8 @@
style="@style/Widget.Auxio.TextView.Primary" style="@style/Widget.Auxio.TextView.Primary"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_large" android:layout_marginStart="@dimen/spacing_mid_large"
android:layout_marginEnd="@dimen/spacing_large" android:layout_marginEnd="@dimen/spacing_mid_large"
app:layout_constraintBottom_toTopOf="@+id/playback_artist" app:layout_constraintBottom_toTopOf="@+id/playback_artist"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -43,8 +43,8 @@
style="@style/Widget.Auxio.TextView.Secondary" style="@style/Widget.Auxio.TextView.Secondary"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_large" android:layout_marginStart="@dimen/spacing_mid_large"
android:layout_marginEnd="@dimen/spacing_large" android:layout_marginEnd="@dimen/spacing_mid_large"
app:layout_constraintBottom_toTopOf="@+id/playback_album" app:layout_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -55,8 +55,8 @@
style="@style/Widget.Auxio.TextView.Secondary" style="@style/Widget.Auxio.TextView.Secondary"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_large" android:layout_marginStart="@dimen/spacing_mid_large"
android:layout_marginEnd="@dimen/spacing_large" android:layout_marginEnd="@dimen/spacing_mid_large"
app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar" app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -108,7 +108,7 @@
android:id="@+id/playback_loop" android:id="@+id/playback_loop"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_large" android:layout_marginEnd="@dimen/spacing_mid_large"
android:contentDescription="@string/desc_change_loop" android:contentDescription="@string/desc_change_loop"
android:src="@drawable/ic_loop" android:src="@drawable/ic_loop"
app:hasIndicator="true" app:hasIndicator="true"
@ -121,7 +121,7 @@
android:id="@+id/playback_skip_prev" android:id="@+id/playback_skip_prev"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_large" android:layout_marginEnd="@dimen/spacing_mid_large"
android:contentDescription="@string/desc_skip_prev" android:contentDescription="@string/desc_skip_prev"
android:src="@drawable/ic_skip_prev" android:src="@drawable/ic_skip_prev"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause" app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
@ -133,7 +133,7 @@
style="@style/Widget.Auxio.FloatingActionButton.PlayPause" style="@style/Widget.Auxio.FloatingActionButton.PlayPause"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_large" android:layout_marginBottom="@dimen/spacing_mid_large"
android:contentDescription="@string/desc_play_pause" android:contentDescription="@string/desc_play_pause"
android:src="@drawable/sel_playing_state" android:src="@drawable/sel_playing_state"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
@ -145,7 +145,7 @@
android:id="@+id/playback_skip_next" android:id="@+id/playback_skip_next"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_large" android:layout_marginStart="@dimen/spacing_mid_large"
android:contentDescription="@string/desc_skip_next" android:contentDescription="@string/desc_skip_next"
android:src="@drawable/ic_skip_next" android:src="@drawable/ic_skip_next"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause" app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
@ -156,7 +156,7 @@
android:id="@+id/playback_shuffle" android:id="@+id/playback_shuffle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_large" android:layout_marginStart="@dimen/spacing_mid_large"
android:contentDescription="@string/desc_shuffle" android:contentDescription="@string/desc_shuffle"
android:src="@drawable/ic_shuffle" android:src="@drawable/ic_shuffle"
app:hasIndicator="true" app:hasIndicator="true"