home: rework loading screen

Move the loading screen into the home view.

Previously, we would use a Snackbar to track the music loading state.
This ended up being a pretty stupid and buggy idea, and would get even
worse with the new music loader changes. Instead, we re-implement the
loading screen into the home view to generally be more sane and
extendable compared to previously.
This commit is contained in:
OxygenCobalt 2022-05-29 20:04:20 -06:00
parent f52fa7f338
commit caaeaca494
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 164 additions and 108 deletions

View file

@ -35,7 +35,6 @@ android {
buildTypes {
debug {
debuggable true
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
}

View file

@ -17,28 +17,23 @@
package org.oxycblt.auxio
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.snackbar.Snackbar
import org.oxycblt.auxio.databinding.FragmentMainBinding
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.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logD
/**
* A wrapper around the home fragment that shows the playback fragment and controls the more
@ -86,11 +81,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// TODO: Move this to a service [automatic rescanning]
musicModel.loadMusic(requireContext())
// Handle the music loader response.
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
handleLoaderResponse(response, permLauncher)
}
navModel.mainNavigationAction.observe(viewLifecycleOwner, ::handleMainNavigation)
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleExploreNavigation)
@ -107,44 +97,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
callback?.isEnabled = false
}
private fun handleLoaderResponse(
response: MusicStore.Response?,
permLauncher: ActivityResultLauncher<String>
) {
val binding = requireBinding()
// Handle any error cases, showing a useful message.
when (response) {
is MusicStore.Response.Err -> {
logD("Received Response.Err")
Snackbar.make(binding.root, R.string.err_load_failed, Snackbar.LENGTH_INDEFINITE)
.apply {
setAction(R.string.lbl_retry) { musicModel.reloadMusic(context) }
show()
}
}
is MusicStore.Response.NoMusic -> {
logD("Received Response.NoMusic")
Snackbar.make(binding.root, R.string.err_no_music, Snackbar.LENGTH_INDEFINITE)
.apply {
setAction(R.string.lbl_retry) { musicModel.reloadMusic(context) }
show()
}
}
is MusicStore.Response.NoPerms -> {
logD("Received Response.NoPerms")
Snackbar.make(binding.root, R.string.err_no_perms, Snackbar.LENGTH_INDEFINITE)
.apply {
setAction(R.string.lbl_grant) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
show()
}
}
else -> {}
}
}
private fun handleMainNavigation(action: MainNavigationAction?) {
if (action == null) return

View file

@ -17,12 +17,17 @@
package org.oxycblt.auxio.home
import android.Manifest
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar
import androidx.core.view.isVisible
import androidx.core.view.iterator
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
@ -51,6 +56,8 @@ import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logTraceOrThrow
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.textSafe
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
@ -69,6 +76,12 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
val sortItem: MenuItem
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.reloadMusic(requireContext())
}
binding.homeToolbar.apply {
sortItem = menu.findItem(R.id.submenu_sorting)
setOnMenuItemClickListener(this@HomeFragment)
@ -76,6 +89,11 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
updateTabConfiguration()
binding.homeLoadingContainer.setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
insets
}
binding.homePager.apply {
adapter = HomePagerAdapter()
@ -104,7 +122,10 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
homeModel.currentTab.observe(viewLifecycleOwner) { tab -> updateCurrentTab(sortItem, tab) }
homeModel.recreateTabs.observe(viewLifecycleOwner, ::handleRecreateTabs)
musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse)
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
handleLoaderResponse(response, permLauncher)
}
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation)
}
@ -236,15 +257,61 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun handleLoaderResponse(response: MusicStore.Response?) {
private fun handleLoaderResponse(
response: MusicStore.Response?,
permLauncher: ActivityResultLauncher<String>
) {
val binding = requireBinding()
when (response) {
is MusicStore.Response.Ok -> binding.homeFab.show()
// While loading or during an error, make sure we keep the shuffle fab hidden so
// that any kind of playback is impossible. PlaybackStateManager also relies on this
// invariant, so please don't change it.
else -> binding.homeFab.hide()
if (response is MusicStore.Response.Ok) {
binding.homeFab.show()
binding.homeLoadingContainer.visibility = View.INVISIBLE
binding.homePager.visibility = View.VISIBLE
} else {
binding.homeFab.hide()
binding.homePager.visibility = View.INVISIBLE
binding.homeLoadingContainer.visibility = View.VISIBLE
logD("Received non-ok response $response")
when (response) {
is MusicStore.Response.Ok -> error("Unreachable")
is MusicStore.Response.Err -> {
logD("Received 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()) }
}
}
is MusicStore.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()) }
}
}
is MusicStore.Response.NoPerms -> {
binding.homeLoadingProgress.visibility = View.INVISIBLE
binding.homeLoadingStatus.textSafe = getString(R.string.err_no_perms)
binding.homeLoadingAction.apply {
visibility = View.VISIBLE
text = getString(R.string.lbl_grant)
setOnClickListener {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
null -> {
binding.homeLoadingStatus.textSafe = getString(R.string.lbl_loading)
binding.homeLoadingAction.visibility = View.INVISIBLE
binding.homeLoadingProgress.visibility = View.VISIBLE
}
}
}
}

View file

@ -53,17 +53,13 @@ class MusicStore private constructor() {
private val callbacks = mutableListOf<Callback>()
/**
* 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) {
response?.let(callback::onMusicUpdate)
callbacks.add(callback)
}
/**
* Remove a callback from this instance.
*/
/** Remove a callback from this instance. */
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
}
@ -78,7 +74,7 @@ class MusicStore private constructor() {
return newResponse
}
private fun loadImpl(context: Context): Response {
private suspend fun loadImpl(context: Context): Response {
val notGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_DENIED

View file

@ -62,8 +62,10 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
val songs = ConcurrentLinkedQueue<Song>()
while (cursor.moveToNext()) {
// Note: This call to buildAudio does not populate the genre field. This is
// because indexing genres is quite slow with MediaStore, and so keeping the
// field blank on unsupported ExoPlayer formats ends up being preferable.
val audio = inner.buildAudio(context, cursor)
val audioUri = requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri
while (true) {
@ -182,11 +184,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
}
companion object {
/**
* The amount of tasks this backend can run efficiently at one time. Eight was chosen here
* as higher values made little difference in speed, and lower values generally caused
* bottlenecks.
*/
/** The amount of tasks this backend can run efficiently at once. */
private const val TASK_CAPACITY = 8
}
}

View file

@ -74,11 +74,11 @@ object Indexer {
// Sanity check: Ensure that all songs are linked up to albums/artists/genres.
for (song in songs) {
if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) {
throw IllegalStateException(
"Found malformed song: ${song.rawName} [" +
"album: ${!song._isMissingAlbum} " +
"artist: ${!song._isMissingArtist} " +
"genre: ${!song._isMissingGenre}]")
error(
"Found unlinked song: ${song.rawName} [" +
"missing album: ${song._isMissingAlbum} " +
"missing artist: ${song._isMissingArtist} " +
"missing genre: ${song._isMissingGenre}]")
}
}
@ -99,8 +99,10 @@ object Indexer {
backend.query(context).use { cursor ->
val loadStart = System.currentTimeMillis()
logD("Successfully queried media database in ${loadStart - queryStart}ms")
val songs = backend.loadSongs(context, cursor)
logD("Successfully loaded songs in ${System.currentTimeMillis() - loadStart}ms")
songs
}
@ -181,7 +183,6 @@ object Indexer {
for (entry in albumsByArtist) {
// The first album will suffice for template metadata.
val templateAlbum = entry.value[0]
artists.add(Artist(rawName = templateAlbum._artistGroupingName, albums = entry.value))
}
@ -213,4 +214,10 @@ object Indexer {
/** Create a list of songs from the [Cursor] queried in [query]. */
fun loadSongs(context: Context, cursor: Cursor): Collection<Song>
}
sealed class Event {
object Query : Event()
object LoadSongs : Event()
object BuildLibrary : Event()
}
}

View file

@ -135,7 +135,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
// The audio is not actually complete at this point, as we cannot obtain a genre
// through a song query. Instead, we have to do the hack where we iterate through
// every genre and assign it's name to each component song.
// every genre and assign it's name to audios that match it's child ID.
context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
@ -149,35 +149,24 @@ abstract class MediaStoreBackend : Indexer.Backend {
// anyway, so we skip genres that have them.
val id = genreCursor.getLong(idIndex)
val name = genreCursor.getStringOrNull(nameIndex) ?: continue
linkGenreAudios(context, id, name, audios)
context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
arrayOf(MediaStore.Audio.Genres.Members._ID)) { cursor ->
val songIdIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
while (cursor.moveToNext()) {
val songId = cursor.getLong(songIdIndex)
audios.find { it.id == songId }?.let { song -> song.genre = name }
}
}
}
}
return audios.map { it.toSong() }
}
/**
* Links up the given genre data ([genreId] and [genreName]) to the child audios connected to
* [genreId].
*/
private fun linkGenreAudios(
context: Context,
genreId: Long,
genreName: String,
audios: List<Audio>
) {
context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, genreId),
arrayOf(MediaStore.Audio.Genres.Members._ID)) { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
audios.find { it.id == id }?.let { song -> song.genre = genreName }
}
}
}
/**
* The projection to use when querying media. Add version-specific columns here in an
* implementation.

View file

@ -90,9 +90,7 @@ class PlaybackStateManager private constructor() {
private val callbacks = mutableListOf<Callback>()
/**
* 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) {
if (isInitialized) {
callback.onNewPlayback(index, queue, parent)

View file

@ -28,13 +28,62 @@
</org.oxycblt.auxio.ui.EdgeAppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/home_pager"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:layout="@layout/fragment_home_list" />
android:animateLayoutChanges="true"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/home_loading_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:paddingStart="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium"
tools:visibility="invisible">
<TextView
android:id="@+id/home_loading_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginBottom="@dimen/spacing_medium"
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
app:layout_constraintBottom_toTopOf="@+id/home_loading_action"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Status" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/home_loading_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
app:indeterminateAnimationType="disjoint"
app:layout_constraintBottom_toBottomOf="@+id/home_loading_action"
app:layout_constraintTop_toTopOf="@+id/home_loading_action"
app:trackColor="@color/sel_track" />
<Button
android:id="@+id/home_loading_action"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/lbl_retry"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/home_loading_status" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/home_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:layout="@layout/fragment_home_list" />
</FrameLayout>
<org.oxycblt.auxio.home.EdgeFabContainer
android:layout_width="wrap_content"

View file

@ -6,6 +6,7 @@
<string name="info_widget_desc">View and control music playback</string>
<!-- Label Namespace | Static Labels -->
<string name="lbl_loading">Loading your music library…</string>
<string name="lbl_retry">Retry</string>
<string name="lbl_grant">Grant</string>