music: add loading progress indicator

Add an indicator to gague the current music loading progress.

This is actually a lot harder to implement than it might seem, not only
due to UI state issues, but also due to the fact that MusicStore needs
to keep it's state sane across a myriad of possible events that could
occur while loading music. This system seems like a good stopgap until
a full service-backed implementation can be created.
This commit is contained in:
OxygenCobalt 2022-06-02 15:16:32 -06:00
parent 14c9bbd4f9
commit bb1d660eae
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 132 additions and 48 deletions

View file

@ -4,10 +4,12 @@
#### What's Improved #### What's Improved
- Loading UI is now more clear and easy-to-use - Loading UI is now more clear and easy-to-use
- Made album/artist/genre grouping order consistent (May change genre images)
#### What's Fixed #### What's Fixed
- Fixed crash when seeking to the end of a track as the track changed to a track with a lower duration - Fixed crash when seeking to the end of a track as the track changed to a track with a lower duration
- Fixed regression where GadgetBridge media controls would no longer work - Fixed regression where GadgetBridge media controls would no longer work
- Fixed bug where music would be incorrectly reloaded on a hot restart
- Fixed issue where the album/artist/genre would not be correctly restored - Fixed issue where the album/artist/genre would not be correctly restored
- Fixed issue where items would not highlight properly in the detail UI - Fixed issue where items would not highlight properly in the detail UI

View file

@ -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.response.collect(::handleLoaderResponse) } launch { musicModel.loadState.collect(::handleLoadEvent) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) } launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
} }
@ -178,7 +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.
if (musicModel.response.value !is MusicStore.Response.Ok) { val state = musicModel.loadState.value
if (!(state is MusicStore.LoadState.Complete && state.response is MusicStore.Response.Ok)) {
return return
} }
@ -256,9 +257,17 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
} }
} }
private fun handleLoaderResponse(response: MusicStore.Response?) { private fun handleLoadEvent(state: MusicStore.LoadState?) {
val binding = requireBinding() val binding = requireBinding()
if (state is MusicStore.LoadState.Complete) {
handleLoaderResponse(binding, state.response)
} else {
handleLoadEvent(binding, state)
}
}
private fun handleLoaderResponse(binding: FragmentHomeBinding, response: MusicStore.Response) {
if (response is MusicStore.Response.Ok) { if (response is MusicStore.Response.Ok) {
binding.homeFab.show() binding.homeFab.show()
binding.homeLoadingContainer.visibility = View.INVISIBLE binding.homeLoadingContainer.visibility = View.INVISIBLE
@ -307,12 +316,27 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
} }
} }
} }
null -> { }
binding.homeLoadingStatus.textSafe = getString(R.string.lbl_loading) }
binding.homeLoadingAction.visibility = View.INVISIBLE }
private fun handleLoadEvent(binding: FragmentHomeBinding, event: MusicStore.LoadState?) {
binding.homePager.visibility = View.INVISIBLE
binding.homeLoadingContainer.visibility = View.VISIBLE
binding.homeLoadingProgress.visibility = View.VISIBLE binding.homeLoadingProgress.visibility = View.VISIBLE
binding.homeLoadingAction.visibility = View.INVISIBLE
if (event is MusicStore.LoadState.Indexing) {
binding.homeLoadingStatus.textSafe =
getString(R.string.fmt_indexing, event.current, event.total)
binding.homeLoadingProgress.apply {
isIndeterminate = false
max = event.total
progress = event.current
} }
} } else {
binding.homeLoadingStatus.textSafe = getString(R.string.lbl_loading)
binding.homeLoadingProgress.isIndeterminate = true
} }
} }

View file

@ -25,6 +25,7 @@ import android.provider.OpenableColumns
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.lang.Exception import java.lang.Exception
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.indexer.Indexer import org.oxycblt.auxio.music.indexer.Indexer
import org.oxycblt.auxio.music.indexer.useQuery import org.oxycblt.auxio.music.indexer.useQuery
@ -65,16 +66,22 @@ class MusicStore private constructor() {
} }
/** Load/Sort the entire music library. Should always be ran on a coroutine. */ /** Load/Sort the entire music library. Should always be ran on a coroutine. */
suspend fun load(context: Context): Response { suspend fun load(context: Context, callback: LoadCallback): Response {
logD("Starting initial music load") logD("Starting initial music load")
val newResponse = withContext(Dispatchers.IO) { loadImpl(context) }.also { response = it }
for (callback in callbacks) { callback.onLoadStateChanged(null)
callback.onMusicUpdate(newResponse) 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 return newResponse
} }
private fun loadImpl(context: Context): Response { private fun loadImpl(context: Context, callback: LoadCallback): Response {
val notGranted = val notGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_DENIED PackageManager.PERMISSION_DENIED
@ -86,10 +93,11 @@ class MusicStore private constructor() {
val response = val response =
try { try {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val library = Indexer.index(context) val library = Indexer.index(context, callback)
if (library != null) { if (library != null) {
logD( logD(
"Music load completed successfully in ${System.currentTimeMillis() - start}ms") "Music load completed successfully in " +
"${System.currentTimeMillis() - start}ms")
Response.Ok(library) Response.Ok(library)
} else { } else {
logE("No music found") logE("No music found")
@ -131,6 +139,25 @@ 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 { sealed class Response {
data class Ok(val library: Library) : Response() data class Ok(val library: Library) : Response()
data class Err(val throwable: Throwable) : Response() data class Err(val throwable: Throwable) : Response()
@ -138,6 +165,7 @@ class MusicStore private constructor() {
object NoPerms : Response() object NoPerms : Response()
} }
/** A callback for awaiting the loading of music. */
interface Callback { interface Callback {
fun onMusicUpdate(response: Response) fun onMusicUpdate(response: Response)
} }

View file

@ -26,11 +26,11 @@ 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] that represents the current music indexing state. */
class MusicViewModel : ViewModel() { class MusicViewModel : ViewModel(), MusicStore.LoadCallback {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val _response = MutableStateFlow<MusicStore.Response?>(null) private val _loadState = MutableStateFlow<MusicStore.LoadState?>(null)
val response: StateFlow<MusicStore.Response?> = _response val loadState: StateFlow<MusicStore.LoadState?> = _loadState
private var isBusy = false private var isBusy = false
@ -39,17 +39,16 @@ class MusicViewModel : ViewModel() {
* navigated to and because SnackBars will have the best UX here. * navigated to and because SnackBars will have the best UX here.
*/ */
fun loadMusic(context: Context) { fun loadMusic(context: Context) {
if (_response.value != null || isBusy) { if (_loadState.value != null || isBusy) {
logD("Loader is busy/already completed, not reloading") logD("Loader is busy/already completed, not reloading")
return return
} }
isBusy = true isBusy = true
_response.value = null _loadState.value = null
viewModelScope.launch { viewModelScope.launch {
val result = musicStore.load(context) musicStore.load(context, this@MusicViewModel)
_response.value = result
isBusy = false isBusy = false
} }
} }
@ -60,7 +59,11 @@ class MusicViewModel : ViewModel() {
*/ */
fun reloadMusic(context: Context) { fun reloadMusic(context: Context) {
logD("Reloading music library") logD("Reloading music library")
_response.value = null _loadState.value = null
loadMusic(context) loadMusic(context)
} }
override fun onLoadStateChanged(state: MusicStore.LoadState?) {
_loadState.value = state
}
} }

View file

@ -30,6 +30,7 @@ import com.google.common.util.concurrent.Futures
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Future import java.util.concurrent.Future
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -57,7 +58,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
// MediaStore. // MediaStore.
override fun query(context: Context) = inner.query(context) override fun query(context: Context) = inner.query(context)
override fun loadSongs(context: Context, cursor: Cursor): Collection<Song> { override fun loadSongs(
context: Context,
cursor: Cursor,
callback: MusicStore.LoadCallback
): 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
// concurrent counterpart to a typical mutable list. // concurrent counterpart to a typical mutable list.
@ -81,8 +86,13 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
Futures.addCallback( Futures.addCallback(
task, task,
AudioCallback(audio, songs, index), AudioCallback(audio) {
Executors.newSingleThreadExecutor()) runningTasks[index] = null
songs.add(it)
callback.onLoadStateChanged(
MusicStore.LoadState.Indexing(songs.size, cursor.count))
},
CALLBACK_EXECUTOR)
runningTasks[index] = task runningTasks[index] = task
@ -100,8 +110,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
private inner class AudioCallback( private inner class AudioCallback(
private val audio: MediaStoreBackend.Audio, private val audio: MediaStoreBackend.Audio,
private val dest: ConcurrentLinkedQueue<Song>, private val onComplete: (Song) -> Unit,
private val taskIndex: Int
) : FutureCallback<TrackGroupArray> { ) : FutureCallback<TrackGroupArray> {
override fun onSuccess(result: TrackGroupArray) { override fun onSuccess(result: TrackGroupArray) {
val metadata = result[0].getFormat(0).metadata val metadata = result[0].getFormat(0).metadata
@ -111,15 +120,13 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
logW("No metadata was found for ${audio.title}") logW("No metadata was found for ${audio.title}")
} }
dest.add(audio.toSong()) onComplete(audio.toSong())
runningTasks[taskIndex] = null
} }
override fun onFailure(t: Throwable) { override fun onFailure(t: Throwable) {
logW("Unable to extract metadata for ${audio.title}") logW("Unable to extract metadata for ${audio.title}")
logW(t.stackTraceToString()) logW(t.stackTraceToString())
dest.add(audio.toSong()) onComplete(audio.toSong())
runningTasks[taskIndex] = null
} }
} }
@ -213,5 +220,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
companion object { companion object {
/** The amount of tasks this backend can run efficiently at once. */ /** The amount of tasks this backend can run efficiently at once. */
private const val TASK_CAPACITY = 8 private const val TASK_CAPACITY = 8
private val CALLBACK_EXECUTOR = Executors.newSingleThreadExecutor()
} }
} }

View file

@ -46,7 +46,7 @@ import org.oxycblt.auxio.util.logD
* @author OxygenCobalt * @author OxygenCobalt
*/ */
object Indexer { object Indexer {
fun index(context: Context): MusicStore.Library? { fun index(context: Context, callback: MusicStore.LoadCallback): MusicStore.Library? {
// Establish the backend to use when initially loading songs. // Establish the backend to use when initially loading songs.
val mediaStoreBackend = val mediaStoreBackend =
when { when {
@ -62,7 +62,7 @@ object Indexer {
// mediaStoreBackend // mediaStoreBackend
// } // }
val songs = buildSongs(context, mediaStoreBackend) val songs = buildSongs(context, mediaStoreBackend, callback)
if (songs.isEmpty()) { if (songs.isEmpty()) {
return null return null
} }
@ -95,17 +95,20 @@ 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(context: Context, backend: Backend): List<Song> { private fun buildSongs(
val queryStart = System.currentTimeMillis() context: Context,
backend: Backend,
callback: MusicStore.LoadCallback
): List<Song> {
val start = System.currentTimeMillis()
var songs = var songs =
backend.query(context).use { cursor -> backend.query(context).use { cursor ->
val loadStart = System.currentTimeMillis() logD(
logD("Successfully queried media database in ${loadStart - queryStart}ms") "Successfully queried media database " +
"in ${System.currentTimeMillis() - start}ms")
val songs = backend.loadSongs(context, cursor) backend.loadSongs(context, cursor, callback)
logD("Successfully loaded songs in ${System.currentTimeMillis() - loadStart}ms")
songs
} }
// Deduplicate songs to prevent (most) deformed music clones // Deduplicate songs to prevent (most) deformed music clones
@ -126,7 +129,7 @@ object Indexer {
// Ensure that sorting order is consistent so that grouping is also consistent. // Ensure that sorting order is consistent so that grouping is also consistent.
Sort.ByName(true).songsInPlace(songs) Sort.ByName(true).songsInPlace(songs)
logD("Successfully loaded ${songs.size} songs") logD("Successfully loaded ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
return songs return songs
} }
@ -223,6 +226,10 @@ object Indexer {
fun query(context: Context): Cursor fun query(context: Context): Cursor
/** Create a list of songs from the [Cursor] queried in [query]. */ /** Create a list of songs from the [Cursor] queried in [query]. */
fun loadSongs(context: Context, cursor: Cursor): Collection<Song> fun loadSongs(
context: Context,
cursor: Cursor,
callback: MusicStore.LoadCallback
): Collection<Song>
} }
} }

View file

@ -26,6 +26,7 @@ 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.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
@ -127,7 +128,15 @@ abstract class MediaStoreBackend : Indexer.Backend {
args.toTypedArray())) { "Content resolver failure: No Cursor returned" } args.toTypedArray())) { "Content resolver failure: No Cursor returned" }
} }
override fun loadSongs(context: Context, cursor: Cursor): Collection<Song> { override fun loadSongs(
context: Context,
cursor: Cursor,
callback: MusicStore.LoadCallback
): 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
// with genre loading and querying the media database itself. As a result, a progress bar
// is not really that applicable.
val audios = mutableListOf<Audio>() val audios = mutableListOf<Audio>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
audios.add(buildAudio(context, cursor)) audios.add(buildAudio(context, cursor))

View file

@ -109,7 +109,7 @@ class SearchFragment :
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
launch { searchModel.searchResults.collect(::updateResults) } launch { searchModel.searchResults.collect(::updateResults) }
launch { musicModel.response.collect(::handleLoaderResponse) } launch { musicModel.loadState.collect(::handleLoadState) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) } launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
} }
@ -171,8 +171,8 @@ class SearchFragment :
requireImm().hide() requireImm().hide()
} }
private fun handleLoaderResponse(response: MusicStore.Response?) { private fun handleLoadState(state: MusicStore.LoadState?) {
if (response is MusicStore.Response.Ok) { if (state is MusicStore.LoadState.Complete && state.response is MusicStore.Response.Ok) {
searchModel.refresh(requireContext()) searchModel.refresh(requireContext())
} }
} }

View file

@ -187,6 +187,8 @@
<string name="fmt_db_pos">+%.1f dB</string> <string name="fmt_db_pos">+%.1f dB</string>
<string name="fmt_db_neg">-%.1f dB</string> <string name="fmt_db_neg">-%.1f dB</string>
<string name="fmt_indexing">Loading your music library… (%1$d/%2$d)</string>
<string name="fmt_songs_loaded">Songs loaded: %d</string> <string name="fmt_songs_loaded">Songs loaded: %d</string>
<string name="fmt_albums_loaded">Albums loaded: %d</string> <string name="fmt_albums_loaded">Albums loaded: %d</string>
<string name="fmt_artists_loaded">Artists loaded: %d</string> <string name="fmt_artists_loaded">Artists loaded: %d</string>