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.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.IndexerViewModel
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.launch
class MainFragment : ViewBindingFragment<FragmentMainBinding>() { class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel 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 private var callback: DynamicBackPressedCallback? = null
override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater) 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 // Initialize music loading. Do it here so that it shows on every fragment that this
// one contains. // one contains.
// TODO: Move this to a service [automatic rescanning] // TODO: Move this to a service [automatic rescanning]
musicModel.loadMusic(requireContext()) musicModel.index(requireContext())
launch { navModel.mainNavigationAction.collect(::handleMainNavigation) } launch { navModel.mainNavigationAction.collect(::handleMainNavigation) }
launch { navModel.exploreNavigationItem.collect(::handleExploreNavigation) } 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre 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.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
@ -70,7 +70,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private val homeModel: HomeViewModel 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 storagePermissionLauncher: ActivityResultLauncher<String>? = null
private var sortItem: MenuItem? = 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 // Build the permission launcher here as you can only do it in onCreateView/onCreate
storagePermissionLauncher = storagePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.reloadMusic(requireContext()) indexerModel.reindex(requireContext())
} }
binding.homeToolbar.apply { binding.homeToolbar.apply {
@ -123,7 +123,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
launch { homeModel.isFastScrolling.collect(::updateFastScrolling) } launch { homeModel.isFastScrolling.collect(::updateFastScrolling) }
launch { homeModel.currentTab.collect(::updateCurrentTab) } launch { homeModel.currentTab.collect(::updateCurrentTab) }
launch { homeModel.recreateTabs.collect(::handleRecreateTabs) } launch { homeModel.recreateTabs.collect(::handleRecreateTabs) }
launch { musicModel.loadState.collect(::handleLoadEvent) } launch { indexerModel.state.collect(::handleIndexerState) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) } 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 // Make sure an update here doesn't mess up the FAB state when it comes to the
// loader response. // loader response.
val state = musicModel.loadState.value val state = indexerModel.state.value
if (!(state is MusicStore.LoadState.Complete && state.response is MusicStore.Response.Ok)) { if (!(state is Indexer.State.Complete && state.response is Indexer.Response.Ok)) {
return 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() val binding = requireBinding()
if (state is MusicStore.LoadState.Complete) { if (state is Indexer.State.Complete) {
handleLoaderResponse(binding, state.response) handleLoaderResponse(binding, state.response)
} else { } else {
handleLoadEvent(binding, state) handleLoadingState(binding, state)
} }
} }
private fun handleLoaderResponse(binding: FragmentHomeBinding, response: MusicStore.Response) { private fun handleLoaderResponse(binding: FragmentHomeBinding, response: Indexer.Response) {
if (response is MusicStore.Response.Ok) { if (response is Indexer.Response.Ok) {
binding.homeFab.show() binding.homeFab.show()
binding.homeLoadingContainer.visibility = View.INVISIBLE binding.homeLoadingContainer.visibility = View.INVISIBLE
binding.homePager.visibility = View.VISIBLE binding.homePager.visibility = View.VISIBLE
@ -280,30 +280,29 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
logD("Received non-ok response $response") logD("Received non-ok response $response")
when (response) { when (response) {
is MusicStore.Response.Ok -> error("Unreachable") is Indexer.Response.Ok -> error("Unreachable")
is MusicStore.Response.Err -> { is Indexer.Response.Err -> {
logD("Received Response.Err")
binding.homeLoadingProgress.visibility = View.INVISIBLE binding.homeLoadingProgress.visibility = View.INVISIBLE
binding.homeLoadingStatus.textSafe = getString(R.string.err_load_failed) binding.homeLoadingStatus.textSafe = getString(R.string.err_load_failed)
binding.homeLoadingAction.apply { binding.homeLoadingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
text = getString(R.string.lbl_retry) 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.homeLoadingProgress.visibility = View.INVISIBLE
binding.homeLoadingStatus.textSafe = getString(R.string.err_no_music) binding.homeLoadingStatus.textSafe = getString(R.string.err_no_music)
binding.homeLoadingAction.apply { binding.homeLoadingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
text = getString(R.string.lbl_retry) text = getString(R.string.lbl_retry)
setOnClickListener { musicModel.reloadMusic(requireContext()) } setOnClickListener { indexerModel.reindex(requireContext()) }
} }
} }
is MusicStore.Response.NoPerms -> { is Indexer.Response.NoPerms -> {
val launcher = val launcher =
requireNotNull(storagePermissionLauncher) { requireNotNull(storagePermissionLauncher) {
"Cannot access permission launcher while in non-view state" "Cannot access permission launcher while detached"
} }
binding.homeLoadingProgress.visibility = View.INVISIBLE 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.homeFab.hide()
binding.homePager.visibility = View.INVISIBLE binding.homePager.visibility = View.INVISIBLE
binding.homeLoadingContainer.visibility = View.VISIBLE binding.homeLoadingContainer.visibility = View.VISIBLE
binding.homeLoadingProgress.visibility = View.VISIBLE binding.homeLoadingProgress.visibility = View.VISIBLE
binding.homeLoadingAction.visibility = View.INVISIBLE binding.homeLoadingAction.visibility = View.INVISIBLE
if (event is MusicStore.LoadState.Indexing) { if (event is Indexer.State.Loading) {
binding.homeLoadingStatus.textSafe = binding.homeLoadingStatus.textSafe =
getString(R.string.fmt_indexing, event.current, event.total) getString(R.string.fmt_indexing, event.current, event.total)
binding.homeLoadingProgress.apply { binding.homeLoadingProgress.apply {

View file

@ -134,9 +134,8 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback
// --- OVERRIDES --- // --- OVERRIDES ---
override fun onMusicUpdate(response: MusicStore.Response) { override fun onLibraryChanged(library: MusicStore.Library?) {
if (response is MusicStore.Response.Ok) { if (library != null) {
val library = response.library
_songs.value = settingsManager.libSongSort.songs(library.songs) _songs.value = settingsManager.libSongSort.songs(library.songs)
_albums.value = settingsManager.libAlbumSort.albums(library.albums) _albums.value = settingsManager.libAlbumSort.albums(library.albums)
_artists.value = settingsManager.libArtistSort.artists(library.artists) _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/>. * 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.Context
import android.content.pm.PackageManager
import android.database.Cursor import android.database.Cursor
import android.os.Build import android.os.Build
import org.oxycblt.auxio.music.Album import androidx.core.content.ContextCompat
import org.oxycblt.auxio.music.Artist import kotlinx.coroutines.Dispatchers
import org.oxycblt.auxio.music.Genre import kotlinx.coroutines.delay
import org.oxycblt.auxio.music.MusicStore import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.Song 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.ui.Sort
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
/** /**
* Auxio's media indexer. * 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 * 3. Using the songs to build the library, which primarily involves linking up all data objects
* with their corresponding parents/children. * with their corresponding parents/children.
* *
* This class in particular handles 3 primarily. For the code that handles 1 and 2, see the other * This class in particular handles 3 primarily. For the code that handles 1 and 2, see the
* files in the module. * [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 * @author OxygenCobalt
*/ */
object Indexer { class Indexer {
fun index(context: Context, callback: MusicStore.LoadCallback): MusicStore.Library? { 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. // Establish the backend to use when initially loading songs.
val mediaStoreBackend = val mediaStoreBackend =
when { when {
@ -54,7 +139,7 @@ object Indexer {
else -> Api21MediaStoreBackend() else -> Api21MediaStoreBackend()
} }
val songs = buildSongs(context, ExoPlayerBackend(mediaStoreBackend), callback) val songs = buildSongs(context, ExoPlayerBackend(mediaStoreBackend), generation)
if (songs.isEmpty()) { if (songs.isEmpty()) {
return null return null
} }
@ -88,11 +173,7 @@ object Indexer {
* [buildGenres] functions must be called with the returned list so that all songs are properly * [buildGenres] functions must be called with the returned list so that all songs are properly
* linked up. * linked up.
*/ */
private fun buildSongs( private fun buildSongs(context: Context, backend: Backend, generation: Long): List<Song> {
context: Context,
backend: Backend,
callback: MusicStore.LoadCallback
): List<Song> {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
var songs = var songs =
@ -101,7 +182,9 @@ object Indexer {
"Successfully queried media database " + "Successfully queried media database " +
"in ${System.currentTimeMillis() - start}ms") "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 // Deduplicate songs to prevent (most) deformed music clones
@ -213,6 +296,25 @@ object Indexer {
return genres 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. */ /** Represents a backend that metadata can be extracted from. */
interface Backend { interface Backend {
/** Query the media database for a basic cursor. */ /** Query the media database for a basic cursor. */
@ -222,7 +324,26 @@ object Indexer {
fun loadSongs( fun loadSongs(
context: Context, context: Context,
cursor: Cursor, cursor: Cursor,
callback: MusicStore.LoadCallback onAddSong: (count: Int, total: Int) -> Unit
): Collection<Song> ): 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 kotlinx.coroutines.launch
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** A [ViewModel] that represents the current music indexing state. */ /** A ViewModel representing the current music indexing state. */
class MusicViewModel : ViewModel(), MusicStore.LoadCallback { class IndexerViewModel : ViewModel(), Indexer.Callback {
private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val _loadState = MutableStateFlow<MusicStore.LoadState?>(null) private val _state = MutableStateFlow<Indexer.State?>(null)
val loadState: StateFlow<MusicStore.LoadState?> = _loadState val state: StateFlow<Indexer.State?> = _state
private var isBusy = false init {
indexer.addCallback(this)
}
/** /** Initiate the indexing process. */
* Initiate the loading process. This is done here since HomeFragment will be the first fragment fun index(context: Context) {
* navigated to and because SnackBars will have the best UX here. if (state.value != null) {
*/ logD("Loader is already loading/is completed, not reloading")
fun loadMusic(context: Context) {
if (_loadState.value != null || isBusy) {
logD("Loader is busy/already completed, not reloading")
return return
} }
isBusy = true indexImpl(context)
_loadState.value = null
viewModelScope.launch {
musicStore.load(context, this@MusicViewModel)
isBusy = false
}
} }
/** /**
* Reload the music library. Note that this call will result in unexpected behavior in the case * 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. * that music is reloaded after a loading process has already exceeded.
*/ */
fun reloadMusic(context: Context) { fun reindex(context: Context) {
logD("Reloading music library") logD("Reloading music library")
_loadState.value = null indexImpl(context)
loadMusic(context)
} }
override fun onLoadStateChanged(state: MusicStore.LoadState?) { private fun indexImpl(context: Context) {
_loadState.value = state 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.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.indexer.id3v2GenreName import org.oxycblt.auxio.music.backend.id3v2GenreName
import org.oxycblt.auxio.music.indexer.withoutArticle import org.oxycblt.auxio.music.backend.withoutArticle
import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -17,20 +17,11 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.core.content.ContextCompat import org.oxycblt.auxio.music.backend.useQuery
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.util.contentResolverSafe 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 * 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 * @author OxygenCobalt
*/ */
class MusicStore private constructor() { 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>() 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. */ /** Add a callback to this instance. Make sure to remove it when done. */
fun addCallback(callback: Callback) { fun addCallback(callback: Callback) {
response?.let(callback::onMusicUpdate) callback.onLibraryChanged(library)
callbacks.add(callback) callbacks.add(callback)
} }
@ -64,53 +52,7 @@ class MusicStore private constructor() {
callbacks.remove(callback) callbacks.remove(callback)
} }
/** Load/Sort the entire music library. Should always be ran on a coroutine. */ /** Represents a library of music owned by [MusicStore]. */
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
}
data class Library( data class Library(
val genres: List<Genre>, val genres: List<Genre>,
val artists: List<Artist>, 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. */ /** A callback for awaiting the loading of music. */
interface Callback { interface Callback {
fun onMusicUpdate(response: Response) fun onLibraryChanged(library: Library?)
} }
companion object { companion object {

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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.content.Context
import android.database.Cursor import android.database.Cursor
@ -31,12 +31,12 @@ import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Future import java.util.concurrent.Future
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor 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.music.Song
import org.oxycblt.auxio.util.logW 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 * 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, * 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( override fun loadSongs(
context: Context, context: Context,
cursor: Cursor, cursor: Cursor,
callback: MusicStore.LoadCallback onAddSong: (count: Int, total: Int) -> Unit
): Collection<Song> { ): Collection<Song> {
// Metadata retrieval with ExoPlayer is asynchronous, so a callback may at any point // 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 // 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) { AudioCallback(audio) {
runningTasks[index] = null runningTasks[index] = null
songs.add(it) songs.add(it)
callback.onLoadStateChanged( onAddSong(songs.size, cursor.count)
MusicStore.LoadState.Indexing(songs.size, cursor.count))
}, },
// Normal JVM dispatcher will suffice here, as there is no IO work // Normal JVM dispatcher will suffice here, as there is no IO work
// going on (and there is no cost from switching contexts with executors) // 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/>. * 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.ContentResolver
import android.content.ContentUris import android.content.ContentUris
@ -41,11 +41,19 @@ fun <R> ContentResolver.useQuery(
block: (Cursor) -> R block: (Cursor) -> R
): R? = queryCursor(uri, projection, selector, args)?.use(block) ): 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. */ /** Converts a [Long] Audio ID into a URI to that particular audio file. */
val Long.audioUri: Uri val Long.audioUri: Uri
get() = get() = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this)
ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, requireNotNull(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 * 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 { return substring(1 until lastIndex).toIntOrNull()?.run {
genreConstantTable.getOrNull(this) genreConstantTable.getOrNull(this)
} ?: this }
?: this
} }
// Current name is fine. // Current name is fine.

View file

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

View file

@ -318,11 +318,11 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore
_repeatMode.value = repeatMode _repeatMode.value = repeatMode
} }
override fun onMusicUpdate(response: MusicStore.Response) { override fun onLibraryChanged(library: MusicStore.Library?) {
if (response is MusicStore.Response.Ok) { if (library != null) {
val action = pendingDelayedAction val action = pendingDelayedAction
if (action != null) { if (action != null) {
performActionImpl(action, response.library) performActionImpl(action, library)
pendingDelayedAction = null 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre 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.Music
import org.oxycblt.auxio.music.MusicParent 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.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.Header import org.oxycblt.auxio.ui.Header
@ -64,7 +64,7 @@ class SearchFragment :
private val searchModel: SearchViewModel by viewModels() private val searchModel: SearchViewModel by viewModels()
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel 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 val searchAdapter = SearchAdapter(this)
private var imm: InputMethodManager? = null private var imm: InputMethodManager? = null
@ -109,7 +109,7 @@ class SearchFragment :
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
launch { searchModel.searchResults.collect(::updateResults) } launch { searchModel.searchResults.collect(::updateResults) }
launch { musicModel.loadState.collect(::handleLoadState) } launch { indexerModel.state.collect(::handleIndexerState) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) } launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
} }
@ -171,8 +171,8 @@ class SearchFragment :
requireImm().hide() requireImm().hide()
} }
private fun handleLoadState(state: MusicStore.LoadState?) { private fun handleIndexerState(state: Indexer.State?) {
if (state is MusicStore.LoadState.Complete && state.response is MusicStore.Response.Ok) { if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
searchModel.refresh(requireContext()) searchModel.refresh(requireContext())
} }
} }