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:
parent
f52fa7f338
commit
caaeaca494
10 changed files with 164 additions and 108 deletions
|
@ -35,7 +35,6 @@ android {
|
|||
|
||||
buildTypes {
|
||||
debug {
|
||||
debuggable true
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-DEBUG"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Reference in a new issue