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:
parent
66b95cef42
commit
e3708bf5f5
12 changed files with 245 additions and 204 deletions
|
@ -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) }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
|
@ -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.
|
|
@ -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.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue