From e3708bf5f5c6178c39ccf6acd37f1b225a04f01e Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Fri, 3 Jun 2022 16:03:08 -0600 Subject: [PATCH] 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. --- .../java/org/oxycblt/auxio/MainFragment.kt | 6 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 43 +++-- .../org/oxycblt/auxio/home/HomeViewModel.kt | 5 +- .../auxio/music/{indexer => }/Indexer.kt | 157 ++++++++++++++++-- ...{MusicViewModel.kt => IndexerViewModel.kt} | 55 +++--- .../java/org/oxycblt/auxio/music/Music.kt | 4 +- .../org/oxycblt/auxio/music/MusicStore.kt | 108 ++---------- .../{indexer => backend}/ExoPlayerBackend.kt | 11 +- .../music/{indexer => backend}/IndexerUtil.kt | 19 ++- .../{indexer => backend}/MediaStoreBackend.kt | 23 +-- .../auxio/playback/PlaybackViewModel.kt | 6 +- .../oxycblt/auxio/search/SearchFragment.kt | 12 +- 12 files changed, 245 insertions(+), 204 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{indexer => }/Indexer.kt (62%) rename app/src/main/java/org/oxycblt/auxio/music/{MusicViewModel.kt => IndexerViewModel.kt} (55%) rename app/src/main/java/org/oxycblt/auxio/music/{indexer => backend}/ExoPlayerBackend.kt (96%) rename app/src/main/java/org/oxycblt/auxio/music/{indexer => backend}/IndexerUtil.kt (93%) rename app/src/main/java/org/oxycblt/auxio/music/{indexer => backend}/MediaStoreBackend.kt (95%) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index e218d54e3..054b13e17 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -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() { 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() { // 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) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 74d8f385e..d81f0ecff 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -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(), 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? = null private var sortItem: MenuItem? = null @@ -81,7 +81,7 @@ class HomeFragment : ViewBindingFragment(), 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(), 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(), 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(), 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(), 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(), 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 { diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 1c3856aa7..e46933aab 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt similarity index 62% rename from app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt rename to app/src/main/java/org/oxycblt/auxio/music/Indexer.kt index 2c1341ba9..6cca1633e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt @@ -15,18 +15,23 @@ * along with this program. If not, see . */ -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() + + 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 { + private fun buildSongs(context: Context, backend: Backend, generation: Long): List { 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 } + + 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 + } + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/IndexerViewModel.kt similarity index 55% rename from app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/music/IndexerViewModel.kt index 8f18525b2..3872ff469 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/IndexerViewModel.kt @@ -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(null) - val loadState: StateFlow = _loadState + private val _state = MutableStateFlow(null) + val state: StateFlow = _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) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index e283c1d3c..9e0ed3f66 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index ff223cf2a..82d64c776 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -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() + 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, val artists: List, @@ -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 { diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/backend/ExoPlayerBackend.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt rename to app/src/main/java/org/oxycblt/auxio/music/backend/ExoPlayerBackend.kt index 0249f35ee..3d40d2afc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/backend/ExoPlayerBackend.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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 { // 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) diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/IndexerUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/backend/IndexerUtil.kt similarity index 93% rename from app/src/main/java/org/oxycblt/auxio/music/indexer/IndexerUtil.kt rename to app/src/main/java/org/oxycblt/auxio/music/backend/IndexerUtil.kt index 6947df757..33a53524c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/indexer/IndexerUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/backend/IndexerUtil.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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 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. diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt similarity index 95% rename from app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt rename to app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt index 8115b0bec..7c189d76d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt @@ -15,18 +15,16 @@ * along with this program. If not, see . */ -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 { // 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. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 3210196de..b48c64652 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 95273a3a8..00d3b5e38 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -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()) } }