music: rework loading management

Rework loading management into a new object, Indexer, which should act
as the base for all future loading changes.

Indexer tries to solve a two issues with music loading:
1. The issue of what occurs when tasks must be restarted or cancelled,
which is common with music loading.
2. Trying to find a good strategy to mediate between service and
ViewModel components in a sensible manner.

Indexer also rolls in a lot of the universal music loading code
alongside this, as much of MusicStore's loading state went unused
by app components and only raised technical challenges when reworking
it.
This commit is contained in:
OxygenCobalt 2022-06-03 16:03:08 -06:00
parent 66b95cef42
commit e3708bf5f5
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 245 additions and 204 deletions

View file

@ -26,8 +26,8 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.IndexerViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction
@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.launch
class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val musicModel: IndexerViewModel by activityViewModels()
private var callback: DynamicBackPressedCallback? = null
override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater)
@ -73,7 +73,7 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// Initialize music loading. Do it here so that it shows on every fragment that this
// one contains.
// TODO: Move this to a service [automatic rescanning]
musicModel.loadMusic(requireContext())
musicModel.index(requireContext())
launch { navModel.mainNavigationAction.collect(::handleMainNavigation) }
launch { navModel.exploreNavigationItem.collect(::handleExploreNavigation) }

View file

@ -44,9 +44,9 @@ import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Indexer
import org.oxycblt.auxio.music.IndexerViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
@ -70,7 +70,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val indexerModel: IndexerViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
private var sortItem: MenuItem? = null
@ -81,7 +81,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// Build the permission launcher here as you can only do it in onCreateView/onCreate
storagePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.reloadMusic(requireContext())
indexerModel.reindex(requireContext())
}
binding.homeToolbar.apply {
@ -123,7 +123,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
launch { homeModel.isFastScrolling.collect(::updateFastScrolling) }
launch { homeModel.currentTab.collect(::updateCurrentTab) }
launch { homeModel.recreateTabs.collect(::handleRecreateTabs) }
launch { musicModel.loadState.collect(::handleLoadEvent) }
launch { indexerModel.state.collect(::handleIndexerState) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
}
@ -178,8 +178,8 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// Make sure an update here doesn't mess up the FAB state when it comes to the
// loader response.
val state = musicModel.loadState.value
if (!(state is MusicStore.LoadState.Complete && state.response is MusicStore.Response.Ok)) {
val state = indexerModel.state.value
if (!(state is Indexer.State.Complete && state.response is Indexer.Response.Ok)) {
return
}
@ -257,18 +257,18 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun handleLoadEvent(state: MusicStore.LoadState?) {
private fun handleIndexerState(state: Indexer.State?) {
val binding = requireBinding()
if (state is MusicStore.LoadState.Complete) {
if (state is Indexer.State.Complete) {
handleLoaderResponse(binding, state.response)
} else {
handleLoadEvent(binding, state)
handleLoadingState(binding, state)
}
}
private fun handleLoaderResponse(binding: FragmentHomeBinding, response: MusicStore.Response) {
if (response is MusicStore.Response.Ok) {
private fun handleLoaderResponse(binding: FragmentHomeBinding, response: Indexer.Response) {
if (response is Indexer.Response.Ok) {
binding.homeFab.show()
binding.homeLoadingContainer.visibility = View.INVISIBLE
binding.homePager.visibility = View.VISIBLE
@ -280,30 +280,29 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
logD("Received non-ok response $response")
when (response) {
is MusicStore.Response.Ok -> error("Unreachable")
is MusicStore.Response.Err -> {
logD("Received Response.Err")
is Indexer.Response.Ok -> error("Unreachable")
is Indexer.Response.Err -> {
binding.homeLoadingProgress.visibility = View.INVISIBLE
binding.homeLoadingStatus.textSafe = getString(R.string.err_load_failed)
binding.homeLoadingAction.apply {
visibility = View.VISIBLE
text = getString(R.string.lbl_retry)
setOnClickListener { musicModel.reloadMusic(requireContext()) }
setOnClickListener { indexerModel.reindex(requireContext()) }
}
}
is MusicStore.Response.NoMusic -> {
is Indexer.Response.NoMusic -> {
binding.homeLoadingProgress.visibility = View.INVISIBLE
binding.homeLoadingStatus.textSafe = getString(R.string.err_no_music)
binding.homeLoadingAction.apply {
visibility = View.VISIBLE
text = getString(R.string.lbl_retry)
setOnClickListener { musicModel.reloadMusic(requireContext()) }
setOnClickListener { indexerModel.reindex(requireContext()) }
}
}
is MusicStore.Response.NoPerms -> {
is Indexer.Response.NoPerms -> {
val launcher =
requireNotNull(storagePermissionLauncher) {
"Cannot access permission launcher while in non-view state"
"Cannot access permission launcher while detached"
}
binding.homeLoadingProgress.visibility = View.INVISIBLE
@ -320,14 +319,14 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun handleLoadEvent(binding: FragmentHomeBinding, event: MusicStore.LoadState?) {
private fun handleLoadingState(binding: FragmentHomeBinding, event: Indexer.State?) {
binding.homeFab.hide()
binding.homePager.visibility = View.INVISIBLE
binding.homeLoadingContainer.visibility = View.VISIBLE
binding.homeLoadingProgress.visibility = View.VISIBLE
binding.homeLoadingAction.visibility = View.INVISIBLE
if (event is MusicStore.LoadState.Indexing) {
if (event is Indexer.State.Loading) {
binding.homeLoadingStatus.textSafe =
getString(R.string.fmt_indexing, event.current, event.total)
binding.homeLoadingProgress.apply {

View file

@ -134,9 +134,8 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback
// --- OVERRIDES ---
override fun onMusicUpdate(response: MusicStore.Response) {
if (response is MusicStore.Response.Ok) {
val library = response.library
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
_songs.value = settingsManager.libSongSort.songs(library.songs)
_albums.value = settingsManager.libAlbumSort.albums(library.albums)
_artists.value = settingsManager.libArtistSort.artists(library.artists)

View file

@ -15,18 +15,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.indexer
package org.oxycblt.auxio.music
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.os.Build
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.backend.Api21MediaStoreBackend
import org.oxycblt.auxio.music.backend.Api30MediaStoreBackend
import org.oxycblt.auxio.music.backend.ExoPlayerBackend
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
/**
* Auxio's media indexer.
@ -40,13 +45,93 @@ import org.oxycblt.auxio.util.logD
* 3. Using the songs to build the library, which primarily involves linking up all data objects
* with their corresponding parents/children.
*
* This class in particular handles 3 primarily. For the code that handles 1 and 2, see the other
* files in the module.
* This class in particular handles 3 primarily. For the code that handles 1 and 2, see the
* [Backend] implementations.
*
* This class also fulfills the role of maintaining the current music loading state, which seems
* like a job for [MusicStore] but in practice is only really leveraged by the components that
* directly work with music loading, making such redundant.
*
* @author OxygenCobalt
*/
object Indexer {
fun index(context: Context, callback: MusicStore.LoadCallback): MusicStore.Library? {
class Indexer {
private var state: State? = null
private var currentGeneration: Long = 0
private val callbacks = mutableListOf<Callback>()
fun addCallback(callback: Callback) {
callback.onIndexerStateChanged(state)
callbacks.add(callback)
}
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
}
suspend fun index(context: Context) {
val generation = synchronized(this) { ++currentGeneration }
val notGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_DENIED
if (notGranted) {
emitState(State.Complete(Response.NoPerms), generation)
}
val response =
try {
val start = System.currentTimeMillis()
val library = withContext(Dispatchers.IO) { indexImpl(context, generation) }
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)
}
emitState(State.Complete(response), generation)
}
/**
* "Cancel" the last job by making it unable to send further state updates. This should be
* called if an object that called [index] is about to be destroyed and thus will have it's task
* canceled, in which it would be useful for any ongoing loading process to not accidentally
* corrupt the current state.
*/
fun cancelLast() {
synchronized(this) {
currentGeneration++
emitState(null, currentGeneration)
}
}
private fun emitState(newState: State?, generation: Long) {
synchronized(this) {
if (currentGeneration == generation) {
state = newState
for (callback in callbacks) {
callback.onIndexerStateChanged(newState)
}
}
}
}
/**
* Run the proper music loading process. [generation] must be a truthful value of the generation
* calling this function.
*/
private fun indexImpl(context: Context, generation: Long): MusicStore.Library? {
emitState(State.Query, generation)
// Establish the backend to use when initially loading songs.
val mediaStoreBackend =
when {
@ -54,7 +139,7 @@ object Indexer {
else -> Api21MediaStoreBackend()
}
val songs = buildSongs(context, ExoPlayerBackend(mediaStoreBackend), callback)
val songs = buildSongs(context, ExoPlayerBackend(mediaStoreBackend), generation)
if (songs.isEmpty()) {
return null
}
@ -88,11 +173,7 @@ object Indexer {
* [buildGenres] functions must be called with the returned list so that all songs are properly
* linked up.
*/
private fun buildSongs(
context: Context,
backend: Backend,
callback: MusicStore.LoadCallback
): List<Song> {
private fun buildSongs(context: Context, backend: Backend, generation: Long): List<Song> {
val start = System.currentTimeMillis()
var songs =
@ -101,7 +182,9 @@ object Indexer {
"Successfully queried media database " +
"in ${System.currentTimeMillis() - start}ms")
backend.loadSongs(context, cursor, callback)
backend.loadSongs(context, cursor) { count, total ->
emitState(State.Loading(count, total), generation)
}
}
// Deduplicate songs to prevent (most) deformed music clones
@ -213,6 +296,25 @@ object Indexer {
return genres
}
/** Represents the current indexer state. */
sealed class State {
object Query : State()
data class Loading(val current: Int, val total: Int) : State()
data class Complete(val response: Response) : State()
}
/** Represents the possible outcomes of a loading process. */
sealed class Response {
data class Ok(val library: MusicStore.Library) : Response()
data class Err(val throwable: Throwable) : Response()
object NoMusic : Response()
object NoPerms : Response()
}
interface Callback {
fun onIndexerStateChanged(state: State?)
}
/** Represents a backend that metadata can be extracted from. */
interface Backend {
/** Query the media database for a basic cursor. */
@ -222,7 +324,26 @@ object Indexer {
fun loadSongs(
context: Context,
cursor: Cursor,
callback: MusicStore.LoadCallback
onAddSong: (count: Int, total: Int) -> Unit
): Collection<Song>
}
companion object {
@Volatile private var INSTANCE: Indexer? = null
/** Get the process-level instance of [Indexer]. */
fun getInstance(): Indexer {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
}
synchronized(this) {
val newInstance = Indexer()
INSTANCE = newInstance
return newInstance
}
}
}
}

View file

@ -25,45 +25,52 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.logD
/** A [ViewModel] that represents the current music indexing state. */
class MusicViewModel : ViewModel(), MusicStore.LoadCallback {
/** A ViewModel representing the current music indexing state. */
class IndexerViewModel : ViewModel(), Indexer.Callback {
private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance()
private val _loadState = MutableStateFlow<MusicStore.LoadState?>(null)
val loadState: StateFlow<MusicStore.LoadState?> = _loadState
private val _state = MutableStateFlow<Indexer.State?>(null)
val state: StateFlow<Indexer.State?> = _state
private var isBusy = false
init {
indexer.addCallback(this)
}
/**
* 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.
*/
fun loadMusic(context: Context) {
if (_loadState.value != null || isBusy) {
logD("Loader is busy/already completed, not reloading")
/** Initiate the indexing process. */
fun index(context: Context) {
if (state.value != null) {
logD("Loader is already loading/is completed, not reloading")
return
}
isBusy = true
_loadState.value = null
viewModelScope.launch {
musicStore.load(context, this@MusicViewModel)
isBusy = false
}
indexImpl(context)
}
/**
* Reload the music library. Note that this call will result in unexpected behavior in the case
* that music is reloaded after a loading process has already exceeded.
*/
fun reloadMusic(context: Context) {
fun reindex(context: Context) {
logD("Reloading music library")
_loadState.value = null
loadMusic(context)
indexImpl(context)
}
override fun onLoadStateChanged(state: MusicStore.LoadState?) {
_loadState.value = state
private fun indexImpl(context: Context) {
viewModelScope.launch { indexer.index(context) }
}
override fun onIndexerStateChanged(state: Indexer.State?) {
_state.value = state
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
musicStore.library = state.response.library
}
}
override fun onCleared() {
super.onCleared()
indexer.cancelLast()
indexer.removeCallback(this)
}
}

View file

@ -23,8 +23,8 @@ import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.indexer.id3v2GenreName
import org.oxycblt.auxio.music.indexer.withoutArticle
import org.oxycblt.auxio.music.backend.id3v2GenreName
import org.oxycblt.auxio.music.backend.withoutArticle
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -17,20 +17,11 @@
package org.oxycblt.auxio.music
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import androidx.core.content.ContextCompat
import java.lang.Exception
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.indexer.Indexer
import org.oxycblt.auxio.music.indexer.useQuery
import org.oxycblt.auxio.music.backend.useQuery
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
/**
* The main storage for music items. Getting an instance of this object is more complicated as it
@ -40,22 +31,19 @@ import org.oxycblt.auxio.util.logE
* @author OxygenCobalt
*/
class MusicStore private constructor() {
private var response: Response? = null
val library: Library?
get() =
response?.let { currentResponse ->
if (currentResponse is Response.Ok) {
currentResponse.library
} else {
null
}
}
private val callbacks = mutableListOf<Callback>()
var library: Library? = null
set(value) {
field = value
for (callback in callbacks) {
callback.onLibraryChanged(library)
}
}
/** Add a callback to this instance. Make sure to remove it when done. */
fun addCallback(callback: Callback) {
response?.let(callback::onMusicUpdate)
callback.onLibraryChanged(library)
callbacks.add(callback)
}
@ -64,53 +52,7 @@ class MusicStore private constructor() {
callbacks.remove(callback)
}
/** Load/Sort the entire music library. Should always be ran on a coroutine. */
suspend fun load(context: Context, callback: LoadCallback): Response {
logD("Starting initial music load")
callback.onLoadStateChanged(null)
val newResponse =
withContext(Dispatchers.IO) { loadImpl(context, callback) }.also { response = it }
callback.onLoadStateChanged(LoadState.Complete(newResponse))
for (responseCallbacks in callbacks) {
responseCallbacks.onMusicUpdate(newResponse)
}
return newResponse
}
private fun loadImpl(context: Context, callback: LoadCallback): Response {
val notGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_DENIED
if (notGranted) {
return Response.NoPerms
}
val response =
try {
val start = System.currentTimeMillis()
val library = Indexer.index(context, callback)
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)
}
return response
}
/** Represents a library of music owned by [MusicStore]. */
data class Library(
val genres: List<Genre>,
val artists: List<Artist>,
@ -138,35 +80,9 @@ class MusicStore private constructor() {
}
}
/** Represents the current state of the loading process. */
sealed class LoadState {
data class Indexing(val current: Int, val total: Int) : LoadState()
data class Complete(val response: Response) : LoadState()
}
/**
* A callback for events that occur during the loading process. This is used by [load] in order
* to have a separate callback interface that is more efficient for rapid-fire updates.
*/
interface LoadCallback {
/**
* Called when the state of the loading process changes. A value of null represents the
* beginning of a loading process.
*/
fun onLoadStateChanged(state: LoadState?)
}
/** Represents the possible outcomes of a loading process. */
sealed class Response {
data class Ok(val library: Library) : Response()
data class Err(val throwable: Throwable) : Response()
object NoMusic : Response()
object NoPerms : Response()
}
/** A callback for awaiting the loading of music. */
interface Callback {
fun onMusicUpdate(response: Response)
fun onLibraryChanged(library: Library?)
}
companion object {

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.indexer
package org.oxycblt.auxio.music.backend
import android.content.Context
import android.database.Cursor
@ -31,12 +31,12 @@ import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Future
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Indexer
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logW
/**
* A [Indexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata.
* A [OldIndexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata.
*
* Normally, leveraging ExoPlayer's metadata system would be a terrible idea, as it is horrifically
* slow. However, if we parallelize it, we can get similar throughput to other metadata extractors,
@ -62,7 +62,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
override fun loadSongs(
context: Context,
cursor: Cursor,
callback: MusicStore.LoadCallback
onAddSong: (count: Int, total: Int) -> Unit
): Collection<Song> {
// Metadata retrieval with ExoPlayer is asynchronous, so a callback may at any point
// add a completed song to the list. To prevent a crash in that case, we use the
@ -90,8 +90,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
AudioCallback(audio) {
runningTasks[index] = null
songs.add(it)
callback.onLoadStateChanged(
MusicStore.LoadState.Indexing(songs.size, cursor.count))
onAddSong(songs.size, cursor.count)
},
// Normal JVM dispatcher will suffice here, as there is no IO work
// going on (and there is no cost from switching contexts with executors)

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.indexer
package org.oxycblt.auxio.music.backend
import android.content.ContentResolver
import android.content.ContentUris
@ -41,11 +41,19 @@ fun <R> ContentResolver.useQuery(
block: (Cursor) -> R
): R? = queryCursor(uri, projection, selector, args)?.use(block)
/**
* For some reason the album art URI namespace does not have a member in [MediaStore], but it still
* works since at least API 21.
*/
private val EXTERNAL_ALBUM_ART_URI = Uri.parse("content://media/external/audio/albumart")
/** Converts a [Long] Audio ID into a URI to that particular audio file. */
val Long.audioUri: Uri
get() =
ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, requireNotNull(this))
get() = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this)
/** Converts a [Long] Album ID into a URI pointing to MediaStore-cached album art. */
val Long.albumCoverUri: Uri
get() = ContentUris.withAppendedId(EXTERNAL_ALBUM_ART_URI, this)
/**
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
@ -105,7 +113,8 @@ val String.id3v2GenreName: String
return substring(1 until lastIndex).toIntOrNull()?.run {
genreConstantTable.getOrNull(this)
} ?: this
}
?: this
}
// Current name is fine.

View file

@ -15,18 +15,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.indexer
package org.oxycblt.auxio.music.backend
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Indexer
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.excluded.ExcludedDatabase
import org.oxycblt.auxio.util.contentResolverSafe
@ -90,8 +88,9 @@ import org.oxycblt.auxio.util.contentResolverSafe
*/
/**
* Represents a [Indexer.Backend] that loads music from the media database ([MediaStore]). This is
* not a fully-featured class by itself, and it's API-specific derivatives should be used instead.
* Represents a [OldIndexer.Backend] that loads music from the media database ([MediaStore]). This
* is not a fully-featured class by itself, and it's API-specific derivatives should be used
* instead.
* @author OxygenCobalt
*/
abstract class MediaStoreBackend : Indexer.Backend {
@ -131,7 +130,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
override fun loadSongs(
context: Context,
cursor: Cursor,
callback: MusicStore.LoadCallback
onAddSong: (count: Int, total: Int) -> Unit
): Collection<Song> {
// Note: We do not actually update the callback with an Indexing state, this is because
// loading music from MediaStore tends to be quite fast, with the only bottlenecks being
@ -280,9 +279,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
_year = year,
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
_albumCoverUri =
ContentUris.withAppendedId(
EXTERNAL_ALBUM_ART_URI,
requireNotNull(albumId) { "Malformed audio: No album id" }),
requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri,
_artistName = artist,
_albumArtistName = albumArtist,
_genreName = genre)
@ -304,12 +301,6 @@ abstract class MediaStoreBackend : Indexer.Backend {
*/
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
/**
* For some reason the album art URI namespace does not have a member in [MediaStore], but
* it still works since at least API 21.
*/
private val EXTERNAL_ALBUM_ART_URI = Uri.parse("content://media/external/audio/albumart")
/**
* The basic projection that works across all versions of android. Is incomplete, hence why
* sub-implementations should be used instead.

View file

@ -318,11 +318,11 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore
_repeatMode.value = repeatMode
}
override fun onMusicUpdate(response: MusicStore.Response) {
if (response is MusicStore.Response.Ok) {
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
val action = pendingDelayedAction
if (action != null) {
performActionImpl(action, response.library)
performActionImpl(action, library)
pendingDelayedAction = null
}
}

View file

@ -35,10 +35,10 @@ import org.oxycblt.auxio.databinding.FragmentSearchBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Indexer
import org.oxycblt.auxio.music.IndexerViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.Header
@ -64,7 +64,7 @@ class SearchFragment :
private val searchModel: SearchViewModel by viewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val indexerModel: IndexerViewModel by activityViewModels()
private val searchAdapter = SearchAdapter(this)
private var imm: InputMethodManager? = null
@ -109,7 +109,7 @@ class SearchFragment :
// --- VIEWMODEL SETUP ---
launch { searchModel.searchResults.collect(::updateResults) }
launch { musicModel.loadState.collect(::handleLoadState) }
launch { indexerModel.state.collect(::handleIndexerState) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
}
@ -171,8 +171,8 @@ class SearchFragment :
requireImm().hide()
}
private fun handleLoadState(state: MusicStore.LoadState?) {
if (state is MusicStore.LoadState.Complete && state.response is MusicStore.Response.Ok) {
private fun handleIndexerState(state: Indexer.State?) {
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
searchModel.refresh(requireContext())
}
}