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 { buildTypes {
debug { debug {
debuggable true
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG" versionNameSuffix = "-DEBUG"
} }

View file

@ -17,28 +17,23 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.Manifest
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.google.android.material.snackbar.Snackbar
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment 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 * 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] // TODO: Move this to a service [automatic rescanning]
musicModel.loadMusic(requireContext()) musicModel.loadMusic(requireContext())
// Handle the music loader response.
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
handleLoaderResponse(response, permLauncher)
}
navModel.mainNavigationAction.observe(viewLifecycleOwner, ::handleMainNavigation) navModel.mainNavigationAction.observe(viewLifecycleOwner, ::handleMainNavigation)
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleExploreNavigation) navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleExploreNavigation)
@ -107,44 +97,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
callback?.isEnabled = false 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?) { private fun handleMainNavigation(action: MainNavigationAction?) {
if (action == null) return if (action == null) return

View file

@ -17,12 +17,17 @@
package org.oxycblt.auxio.home package org.oxycblt.auxio.home
import android.Manifest
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem 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.appcompat.widget.Toolbar
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController 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.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logTraceOrThrow import org.oxycblt.auxio.util.logTraceOrThrow
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.textSafe
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -69,6 +76,12 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
val sortItem: MenuItem 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 { binding.homeToolbar.apply {
sortItem = menu.findItem(R.id.submenu_sorting) sortItem = menu.findItem(R.id.submenu_sorting)
setOnMenuItemClickListener(this@HomeFragment) setOnMenuItemClickListener(this@HomeFragment)
@ -76,6 +89,11 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
updateTabConfiguration() updateTabConfiguration()
binding.homeLoadingContainer.setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
insets
}
binding.homePager.apply { binding.homePager.apply {
adapter = HomePagerAdapter() adapter = HomePagerAdapter()
@ -104,7 +122,10 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
homeModel.currentTab.observe(viewLifecycleOwner) { tab -> updateCurrentTab(sortItem, tab) } homeModel.currentTab.observe(viewLifecycleOwner) { tab -> updateCurrentTab(sortItem, tab) }
homeModel.recreateTabs.observe(viewLifecycleOwner, ::handleRecreateTabs) homeModel.recreateTabs.observe(viewLifecycleOwner, ::handleRecreateTabs)
musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse) musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
handleLoaderResponse(response, permLauncher)
}
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation) 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() 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 if (response is MusicStore.Response.Ok) {
// that any kind of playback is impossible. PlaybackStateManager also relies on this binding.homeFab.show()
// invariant, so please don't change it. binding.homeLoadingContainer.visibility = View.INVISIBLE
else -> binding.homeFab.hide() 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>() 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) { fun addCallback(callback: Callback) {
response?.let(callback::onMusicUpdate) response?.let(callback::onMusicUpdate)
callbacks.add(callback) callbacks.add(callback)
} }
/** /** Remove a callback from this instance. */
* Remove a callback from this instance.
*/
fun removeCallback(callback: Callback) { fun removeCallback(callback: Callback) {
callbacks.remove(callback) callbacks.remove(callback)
} }
@ -78,7 +74,7 @@ class MusicStore private constructor() {
return newResponse return newResponse
} }
private fun loadImpl(context: Context): Response { private suspend fun loadImpl(context: Context): 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

View file

@ -62,8 +62,10 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
val songs = ConcurrentLinkedQueue<Song>() val songs = ConcurrentLinkedQueue<Song>()
while (cursor.moveToNext()) { 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 audio = inner.buildAudio(context, cursor)
val audioUri = requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri val audioUri = requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri
while (true) { while (true) {
@ -182,11 +184,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 one time. Eight was chosen here
* as higher values made little difference in speed, and lower values generally caused
* bottlenecks.
*/
private const val TASK_CAPACITY = 8 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. // Sanity check: Ensure that all songs are linked up to albums/artists/genres.
for (song in songs) { for (song in songs) {
if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) { if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) {
throw IllegalStateException( error(
"Found malformed song: ${song.rawName} [" + "Found unlinked song: ${song.rawName} [" +
"album: ${!song._isMissingAlbum} " + "missing album: ${song._isMissingAlbum} " +
"artist: ${!song._isMissingArtist} " + "missing artist: ${song._isMissingArtist} " +
"genre: ${!song._isMissingGenre}]") "missing genre: ${song._isMissingGenre}]")
} }
} }
@ -99,8 +99,10 @@ object Indexer {
backend.query(context).use { cursor -> backend.query(context).use { cursor ->
val loadStart = System.currentTimeMillis() val loadStart = System.currentTimeMillis()
logD("Successfully queried media database in ${loadStart - queryStart}ms") logD("Successfully queried media database in ${loadStart - queryStart}ms")
val songs = backend.loadSongs(context, cursor) val songs = backend.loadSongs(context, cursor)
logD("Successfully loaded songs in ${System.currentTimeMillis() - loadStart}ms") logD("Successfully loaded songs in ${System.currentTimeMillis() - loadStart}ms")
songs songs
} }
@ -181,7 +183,6 @@ object Indexer {
for (entry in albumsByArtist) { for (entry in albumsByArtist) {
// The first album will suffice for template metadata. // The first album will suffice for template metadata.
val templateAlbum = entry.value[0] val templateAlbum = entry.value[0]
artists.add(Artist(rawName = templateAlbum._artistGroupingName, albums = entry.value)) 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]. */ /** 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): 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 // 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 // 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( context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
@ -149,35 +149,24 @@ abstract class MediaStoreBackend : Indexer.Backend {
// anyway, so we skip genres that have them. // anyway, so we skip genres that have them.
val id = genreCursor.getLong(idIndex) val id = genreCursor.getLong(idIndex)
val name = genreCursor.getStringOrNull(nameIndex) ?: continue 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() } 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 * The projection to use when querying media. Add version-specific columns here in an
* implementation. * implementation.

View file

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

View file

@ -28,13 +28,62 @@
</org.oxycblt.auxio.ui.EdgeAppBarLayout> </org.oxycblt.auxio.ui.EdgeAppBarLayout>
<androidx.viewpager2.widget.ViewPager2 <FrameLayout
android:id="@+id/home_pager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" android:animateLayoutChanges="true"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
tools:layout="@layout/fragment_home_list" />
<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 <org.oxycblt.auxio.home.EdgeFabContainer
android:layout_width="wrap_content" android:layout_width="wrap_content"

View file

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