music: make musicloader async

Make MusicLoader instantiation fully asynchronous. This implementation
changes a lot about Auxio. For one, the loading screen is now gone.
However, many parts of the app now run under the fact that MusicStore
might not be available. However, I don't think there will be too much bugs
from it. Some more changes will be made to improve this implementation.
This commit is contained in:
OxygenCobalt 2021-10-24 20:01:15 -06:00
parent 926fef4218
commit fe0c2761c7
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
22 changed files with 286 additions and 503 deletions

View file

@ -58,8 +58,6 @@ class MainFragment : Fragment() {
// --- VIEWMODEL SETUP ---
playbackModel.setupPlayback(requireContext())
// Change CompactPlaybackFragment's visibility here so that an animation occurs.
binding.mainPlayback.isVisible = playbackModel.song.value != null

View file

@ -78,23 +78,28 @@ class DetailViewModel : ViewModel() {
private var currentMenuContext: DisplayMode? = null
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
fun setGenre(id: Long, context: Context) {
if (mCurGenre.value?.id == id) return
val musicStore = MusicStore.requireInstance()
mCurGenre.value = musicStore.genres.find { it.id == id }
refreshGenreData(context)
}
fun setArtist(id: Long, context: Context) {
if (mCurArtist.value?.id == id) return
val musicStore = MusicStore.requireInstance()
mCurArtist.value = musicStore.artists.find { it.id == id }
refreshArtistData(context)
}
fun setAlbum(id: Long, context: Context) {
if (mCurAlbum.value?.id == id) return
val musicStore = MusicStore.requireInstance()
mCurAlbum.value = musicStore.albums.find { it.id == id }
refreshAlbumData(context)
}

View file

@ -18,12 +18,16 @@
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 android.view.ViewGroup
import android.widget.Button
import androidx.activity.result.contract.ActivityResultContracts
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.iterator
import androidx.core.view.updatePadding
import androidx.core.view.updatePaddingRelative
@ -34,6 +38,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
@ -46,6 +51,7 @@ 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.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
@ -73,6 +79,13 @@ class HomeFragment : Fragment() {
var bottomPadding = 0
val sortItem: MenuItem
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {
homeModel.reloadMusic(requireContext())
}
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
@ -193,22 +206,96 @@ class HomeFragment : Fragment() {
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) = homeModel.updateCurrentTab(position)
})
TabLayoutMediator(binding.homeTabs, this) { tab, pos ->
tab.setText(homeModel.tabs[pos].string)
}.attach()
}
binding.homeFab.setOnClickListener {
playbackModel.shuffleAll()
}
TabLayoutMediator(binding.homeTabs, binding.homePager) { tab, pos ->
tab.setText(homeModel.tabs[pos].string)
}.attach()
// --- VIEWMODEL SETUP ---
// Initialize music loading. Unlike MainFragment, we can not only do this here on startup
// but also show a SnackBar in a reasonable place in this fragment.
homeModel.loadMusic(requireContext())
// There is no way a fast scrolling event can continue across a re-create. Reset it.
homeModel.updateFastScrolling(false)
homeModel.loaderResponse.observe(viewLifecycleOwner) { response ->
// Handle the loader response.
when (response) {
is MusicStore.Response.Ok -> {
logD("Received Ok")
binding.homeFab.show()
playbackModel.setupPlayback(requireContext())
}
is MusicStore.Response.Err -> {
logD("Received Error")
// We received an error. Hide the FAB and show a Snackbar with the error
// message and a corresponding action
binding.homeFab.hide()
val errorRes = when (response.kind) {
MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music
MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms
MusicStore.ErrorKind.FAILED -> R.string.err_load_failed
}
val snackbar = Snackbar.make(
binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE
)
snackbar.view.apply {
// Change the font family to our semibold color
findViewById<Button>(
com.google.android.material.R.id.snackbar_action
).typeface = ResourcesCompat.getFont(requireContext(), R.font.inter_semibold)
fitsSystemWindows = false
// Prevent fitsSystemWindows margins from being applied to this view
// [We already do it]
setOnApplyWindowInsetsListener { v, insets -> insets }
}
when (response.kind) {
MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> {
snackbar.setAction(R.string.lbl_retry) {
homeModel.reloadMusic(requireContext())
}
}
MusicStore.ErrorKind.NO_PERMS -> {
snackbar.setAction(R.string.lbl_grant) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
snackbar.show()
}
// While loading or during an error, make sure we keep the shuffle fab hidden so
// that any kind of loading is impossible. PlaybackStateManager also relies on this
// invariant, so please don't change it.
null -> binding.homeFab.hide()
}
}
homeModel.fastScrolling.observe(viewLifecycleOwner) { scrolling ->
// Make sure an update here doesn't mess up the FAB state when it comes to the
// loader response.
if (homeModel.loaderResponse.value !is MusicStore.Response.Ok) {
return@observe
}
if (scrolling) {
binding.homeFab.hide()
} else {

View file

@ -18,9 +18,12 @@
package org.oxycblt.auxio.home
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
@ -36,13 +39,8 @@ import org.oxycblt.auxio.ui.SortMode
* @author OxygenCobalt
*/
class HomeViewModel : ViewModel(), SettingsManager.Callback {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
/** Internal getter for getting the visible library tabs */
private val visibleTabs: List<DisplayMode> get() = settingsManager.libTabs
.filterIsInstance<Tab.Visible>().map { it.mode }
private val mSongs = MutableLiveData(listOf<Song>())
val songs: LiveData<List<Song>> get() = mSongs
@ -58,12 +56,13 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
var tabs: List<DisplayMode> = visibleTabs
private set
/** Internal getter for getting the visible library tabs */
private val visibleTabs: List<DisplayMode> get() = settingsManager.libTabs
.filterIsInstance<Tab.Visible>().map { it.mode }
private val mCurTab = MutableLiveData(tabs[0])
val curTab: LiveData<DisplayMode> = mCurTab
private val mFastScrolling = MutableLiveData(false)
val fastScrolling: LiveData<Boolean> = mFastScrolling
/**
* Marker to recreate all library tabs, usually initiated by a settings change.
* When this flag is set, all tabs (and their respective viewpager fragments) will be
@ -72,13 +71,51 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
private val mRecreateTabs = MutableLiveData(false)
val recreateTabs: LiveData<Boolean> = mRecreateTabs
private val mFastScrolling = MutableLiveData(false)
val fastScrolling: LiveData<Boolean> = mFastScrolling
private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null)
val loaderResponse: LiveData<MusicStore.Response?> = mLoaderResponse
private var isBusy = false
init {
settingsManager.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 (mLoaderResponse.value != null || isBusy) {
return
}
isBusy = true
mLoaderResponse.value = null
viewModelScope.launch {
val result = MusicStore.initInstance(context)
isBusy = false
mLoaderResponse.value = result
if (result is MusicStore.Response.Ok) {
val musicStore = result.musicStore
mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.sortModels(musicStore.artists)
mGenres.value = settingsManager.libGenreSort.sortModels(musicStore.genres)
}
}
}
settingsManager.addCallback(this)
fun reloadMusic(context: Context) {
mLoaderResponse.value = null
loadMusic(context)
}
/**

View file

@ -38,6 +38,7 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.resolveAttr
import org.oxycblt.auxio.util.resolveDrawable
import kotlin.math.abs
@ -47,13 +48,13 @@ import kotlin.math.abs
* Zhanghi's AndroidFastScroll but slimmed down for Auxio and with a couple of enhancements.
*
* Attributions as per the Apache 2.0 license:
* ORIGINAL AUTHOR: Zhanghai [https://github.com/zhanghai]
* ORIGINAL AUTHOR: Hai Zhang [https://github.com/zhanghai]
* PROJECT: Android Fast Scroll [https://github.com/zhanghai/AndroidFastScroll]
* MODIFIER: OxygenCobalt [https://github.com/]
*
* !!! MODIFICATIONS !!!:
* - Scroller will no longer show itself on startup, which looked unpleasent with multiple
* views
* - Scroller will no longer show itself on startup or relayouts, which looked unpleasant
* with multiple views
* - DefaultAnimationHelper and RecyclerViewHelper were merged into the class
* - FastScroller overlay was merged into RecyclerView instance
* - Removed FastScrollerBuilder
@ -65,8 +66,6 @@ import kotlin.math.abs
* - Added drag listener
* - TODO: Added documentation
* - TODO: Popup will center itself to the thumb when possible
*
* TODO: Debug this
*/
class FastScrollRecyclerView @JvmOverloads constructor(
context: Context,
@ -193,10 +192,6 @@ class FastScrollRecyclerView @JvmOverloads constructor(
})
}
fun addPopupProvider(provider: (Int) -> String) {
popupProvider = provider
}
// --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() {
@ -316,6 +311,10 @@ class FastScrollRecyclerView @JvmOverloads constructor(
}
private fun updateScrollbarState() {
if (!canScroll() || childCount == 0) {
return
}
// Getting a pixel-perfect scroll position from a recyclerview is a bit of an involved
// process. It's kind of expected given how RecyclerView well...recycles views, but it's
// still very annoying how many hoops one has to jump through.

View file

@ -39,7 +39,7 @@ import kotlin.math.sqrt
* This is an adaptation from AndroidFastScroll's MD2 theme.
*
* Attributions as per the Apache 2.0 license:
* ORIGINAL AUTHOR: Zhanghai [https://github.com/zhanghai]
* ORIGINAL AUTHOR: Hai Zhang [https://github.com/zhanghai]
* PROJECT: Android Fast Scroll [https://github.com/zhanghai/AndroidFastScroll]
* MODIFIER: OxygenCobalt [https://github.com/]
*
@ -47,7 +47,6 @@ import kotlin.math.sqrt
* - Use modified Auxio resources instead of AFS resources
* - Variable names are no longer prefixed with m
* - Suppressed deprecation warning when dealing with convexness
* - TODO: Popup will center itself to the thumb when possible
*/
class Md2PopupBackground(context: Context) : Drawable() {
private val paint: Paint = Paint()

View file

@ -29,7 +29,6 @@ import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.SortMode
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.ui.sliceArticle
import org.oxycblt.auxio.util.applySpans
class SongListFragment : HomeListFragment() {
override fun onCreateView(
@ -47,7 +46,6 @@ class SongListFragment : HomeListFragment() {
)
setupRecycler(R.id.home_song_list, adapter, homeModel.songs)
binding.homeRecycler.applySpans { it == 0 }
return binding.root
}

View file

@ -1,197 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* LoadingFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.loading
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentLoadingBinding
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.util.logD
/**
* Fragment that handles what to display during the loading process.
* TODO: Figure out how to phase out the loading screen since
* Android 12 is annoyingly stubborn about having one splash
* screen and one splash screen only.
* @author OxygenCobalt
*/
class LoadingFragment : Fragment() {
private val loadingModel: LoadingViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentLoadingBinding.inflate(inflater)
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ::onPermResult
)
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
binding.loadingModel = loadingModel
// --- VIEWMODEL SETUP ---
loadingModel.doGrant.observe(viewLifecycleOwner) { doGrant ->
if (doGrant) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
loadingModel.doneWithGrant()
}
}
loadingModel.response.observe(viewLifecycleOwner) { response ->
when (response) {
// Success should lead to navigation to the main fragment
MusicStore.Response.SUCCESS -> findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
// Null means that the loading process is going on
null -> showLoading(binding)
// Anything else is an error
else -> showError(binding, response)
}
}
if (hasNoPermissions()) {
// MusicStore.Response.NO_PERMS isnt actually returned by MusicStore, its just
// a way to keep the current permission state across device changes
loadingModel.notifyNoPermissions()
}
if (loadingModel.response.value == null) {
loadingModel.load(requireContext())
}
logD("Fragment created")
return binding.root
}
override fun onResume() {
super.onResume()
// Navigate away if the music has already been loaded.
// This causes a memory leak, but there's nothing I can do about it.
if (loadingModel.loaded) {
findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
}
}
// --- PERMISSIONS ---
/**
* Check if Auxio has the permissions to load music
*/
private fun hasNoPermissions(): Boolean {
val needRationale = shouldShowRequestPermissionRationale(
Manifest.permission.READ_EXTERNAL_STORAGE
)
val notGranted = ContextCompat.checkSelfPermission(
requireContext(), Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_DENIED
return needRationale || notGranted
}
private fun onPermResult(granted: Boolean) {
if (granted) {
// If granted, its now safe to load, which will clear the NO_PERMS response
// we applied earlier.
loadingModel.load(requireContext())
}
}
// --- UI DISPLAY ---
/**
* Hide all error elements and return to the loading view
*/
private fun showLoading(binding: FragmentLoadingBinding) {
binding.apply {
loadingErrorText.visibility = View.INVISIBLE
loadingActionButton.visibility = View.INVISIBLE
loadingCircle.visibility = View.VISIBLE
}
}
/**
* Show an error prompt.
* @param error The [MusicStore.Response] that this error corresponds to. Ignores
* [MusicStore.Response.SUCCESS]
*/
private fun showError(binding: FragmentLoadingBinding, error: MusicStore.Response) {
binding.loadingCircle.visibility = View.GONE
binding.loadingErrorText.visibility = View.VISIBLE
binding.loadingActionButton.visibility = View.VISIBLE
when (error) {
MusicStore.Response.NO_MUSIC -> {
binding.loadingErrorText.text = getString(R.string.err_no_music)
binding.loadingActionButton.apply {
setText(R.string.lbl_retry)
setOnClickListener {
loadingModel.load(context)
}
}
}
MusicStore.Response.FAILED -> {
binding.loadingErrorText.text = getString(R.string.err_load_failed)
binding.loadingActionButton.apply {
setText(R.string.lbl_retry)
setOnClickListener {
loadingModel.load(context)
}
}
}
MusicStore.Response.NO_PERMS -> {
binding.loadingErrorText.text = getString(R.string.err_no_perms)
binding.loadingActionButton.apply {
setText(R.string.lbl_grant)
setOnClickListener {
loadingModel.grant()
}
}
}
else -> {}
}
}
}

View file

@ -1,82 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* LoadingViewModel.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.loading
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.music.MusicStore
/**
* ViewModel responsible for the loading UI and beginning the loading process overall.
* @author OxygenCobalt
*/
class LoadingViewModel : ViewModel() {
private val mResponse = MutableLiveData<MusicStore.Response?>(null)
private val mDoGrant = MutableLiveData(false)
private var isBusy = false
/** The last response from [MusicStore]. Null if the loading process is occurring. */
val response: LiveData<MusicStore.Response?> = mResponse
val doGrant: LiveData<Boolean> = mDoGrant
val loaded: Boolean get() = musicStore.loaded
private val musicStore = MusicStore.getInstance()
/**
* Begin the music loading process. The response from MusicStore is pushed to [response]
*/
fun load(context: Context) {
// Dont start a new load if the last one hasnt finished
if (isBusy) return
isBusy = true
mResponse.value = null
viewModelScope.launch {
mResponse.value = musicStore.load(context)
isBusy = false
}
}
/**
* Show the grant prompt.
*/
fun grant() {
mDoGrant.value = true
}
/**
* Mark that the grant prompt is now shown.
*/
fun doneWithGrant() {
mDoGrant.value = false
}
/**
* Notify this viewmodel that there are no permissions
*/
fun notifyNoPermissions() {
mResponse.value = MusicStore.Response.NO_PERMS
}
}

View file

@ -18,11 +18,14 @@
package org.oxycblt.auxio.music
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.util.logD
@ -46,25 +49,20 @@ class MusicStore private constructor() {
private var mSongs = listOf<Song>()
val songs: List<Song> get() = mSongs
/** Marker for whether the music loading process has successfully completed. */
var loaded = false
private set
/**
* Load/Sort the entire music library. Should always be ran on a coroutine.
*/
suspend fun load(context: Context): Response {
return withContext(Dispatchers.IO) {
loadMusicInternal(context)
}
}
/**
* Do the actual music loading process internally.
*/
private fun loadMusicInternal(context: Context): Response {
private fun load(context: Context): Response {
logD("Starting initial music load...")
val notGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_DENIED
if (notGranted) {
return Response.Err(ErrorKind.NO_PERMS)
}
try {
val start = System.currentTimeMillis()
@ -72,7 +70,7 @@ class MusicStore private constructor() {
loader.load()
if (loader.songs.isEmpty()) {
return Response.NO_MUSIC
return Response.Err(ErrorKind.NO_MUSIC)
}
mSongs = loader.songs
@ -85,12 +83,10 @@ class MusicStore private constructor() {
logE("Something went horribly wrong.")
logE(e.stackTraceToString())
return Response.FAILED
return Response.Err(ErrorKind.FAILED)
}
loaded = true
return Response.SUCCESS
return Response.Ok(this)
}
/**
@ -121,31 +117,77 @@ class MusicStore private constructor() {
}
/**
* Responses that [MusicStore] sends back when a [load] call completes.
* A response that [MusicStore] returns when loading music.
* And before you ask, yes, I do like rust.
*/
enum class Response {
NO_MUSIC, NO_PERMS, FAILED, SUCCESS
sealed class Response {
class Ok(val musicStore: MusicStore) : Response()
class Err(val kind: ErrorKind) : Response()
}
enum class ErrorKind {
NO_PERMS, NO_MUSIC, FAILED
}
companion object {
@Volatile
private var INSTANCE: MusicStore? = null
private var INSTANCE: Response? = null
/**
* Get/Instantiate the single instance of [MusicStore].
* Initialize the loading process for this instance. This must be ran on a background
* thread. If the instance has already been loaded successfully, then it will be returned
* immediately.
*/
fun getInstance(): MusicStore {
suspend fun initInstance(context: Context): Response {
val currentInstance = INSTANCE
if (currentInstance != null) {
if (currentInstance is Response.Ok) {
return currentInstance
}
return withContext(Dispatchers.IO) {
val result = MusicStore().load(context)
synchronized(this) {
val newInstance = MusicStore()
INSTANCE = newInstance
return newInstance
INSTANCE = result
}
result
}
}
/**
* Maybe get a MusicStore instance.
*
* @return null if the music store instance is still loading or if the loading process has
* encountered an error. An instance is returned otherwise.
*/
fun maybeGetInstance(): MusicStore? {
val currentInstance = INSTANCE
return if (currentInstance is Response.Ok) {
currentInstance.musicStore
} else {
null
}
}
/**
* Require a MusicStore instance. This function is dangerous and should only be used if
* it's guaranteed that the caller's code will only be called after the initial loading
* process.
*/
fun requireInstance(): MusicStore {
return requireNotNull(maybeGetInstance()) {
"MusicStore instance was not loaded or loading failed."
}
}
/**
* Check if this instance has successfully loaded or not.
*/
fun loaded(): Boolean {
return maybeGetInstance() != null
}
}
}

View file

@ -99,9 +99,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
it.slice((mIndex.value!! + 1) until it.size)
}
private val playbackManager = PlaybackStateManager.getInstance()
private val playbackManager = PlaybackStateManager.maybeGetInstance()
private val settingsManager = SettingsManager.getInstance()
private val musicStore = MusicStore.getInstance()
init {
playbackManager.addCallback(this)
@ -173,7 +172,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
*/
fun playWithUri(uri: Uri, context: Context) {
// Check if everything is already running to run the URI play
if (playbackManager.isRestored && musicStore.loaded) {
if (playbackManager.isRestored && MusicStore.loaded()) {
playWithUriInternal(uri, context)
} else {
logD("Cant play this URI right now, waiting...")
@ -189,18 +188,13 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
private fun playWithUriInternal(uri: Uri, context: Context) {
logD("Playing with uri $uri")
val musicStore = MusicStore.requireInstance()
musicStore.findSongForUri(uri, context.contentResolver)?.let { song ->
playSong(song)
}
}
/**
* Play all songs
*/
fun playAll() {
playbackManager.playAll()
}
/**
* Shuffle all songs
*/
@ -370,6 +364,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
* Restore playback on startup. This can do one of two things:
* - Play a file intent that was given by MainActivity in [playWithUri]
* - Restore the last playback state if there is no active file intent.
* TODO: Re-add this to HomeFragment once state can be restored
*/
fun setupPlayback(context: Context) {
val intentUri = mIntentUri

View file

@ -38,7 +38,7 @@ import org.oxycblt.auxio.util.logE
* - If you want to use the playback state in the UI, use [org.oxycblt.auxio.playback.PlaybackViewModel] as it can withstand volatile UIs.
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use [org.oxycblt.auxio.playback.system.PlaybackService].
*
* All access should be done with [PlaybackStateManager.getInstance].
* All access should be done with [PlaybackStateManager.maybeGetInstance].
* @author OxygenCobalt
*/
class PlaybackStateManager private constructor() {
@ -132,7 +132,6 @@ class PlaybackStateManager private constructor() {
val hasPlayed: Boolean get() = mHasPlayed
private val settingsManager = SettingsManager.getInstance()
private val musicStore = MusicStore.getInstance()
// --- CALLBACKS ---
@ -164,6 +163,8 @@ class PlaybackStateManager private constructor() {
when (mode) {
PlaybackMode.ALL_SONGS -> {
val musicStore = MusicStore.requireInstance()
mParent = null
mQueue = musicStore.songs.toMutableList()
}
@ -231,22 +232,12 @@ class PlaybackStateManager private constructor() {
updatePlayback(mQueue[0])
}
/**
* Play all songs.
*/
fun playAll() {
mMode = PlaybackMode.ALL_SONGS
mQueue = musicStore.songs.toMutableList()
mParent = null
setShuffling(false, keepSong = false)
updatePlayback(mQueue[0])
}
/**
* Shuffle all songs.
*/
fun shuffleAll() {
val musicStore = MusicStore.maybeGetInstance() ?: return
mMode = PlaybackMode.ALL_SONGS
mQueue = musicStore.songs.toMutableList()
mParent = null
@ -478,11 +469,17 @@ class PlaybackStateManager private constructor() {
private fun resetShuffle(keepSong: Boolean, useLastSong: Boolean) {
val lastSong = if (useLastSong) mQueue[mIndex] else mSong
val musicStore = MusicStore.requireInstance()
mQueue = when (mMode) {
PlaybackMode.IN_ARTIST -> orderSongsInArtist(mParent as Artist)
PlaybackMode.IN_ALBUM -> orderSongsInAlbum(mParent as Album)
PlaybackMode.IN_GENRE -> orderSongsInGenre(mParent as Genre)
PlaybackMode.ALL_SONGS -> orderSongs()
PlaybackMode.ALL_SONGS ->
settingsManager.libSongSort.sortSongs(musicStore.songs).toMutableList()
PlaybackMode.IN_ALBUM ->
settingsManager.detailAlbumSort.sortAlbum(mParent as Album).toMutableList()
PlaybackMode.IN_ARTIST ->
settingsManager.detailArtistSort.sortArtist(mParent as Artist).toMutableList()
PlaybackMode.IN_GENRE ->
settingsManager.detailGenreSort.sortGenre(mParent as Genre).toMutableList()
}
if (keepSong) {
@ -610,8 +607,10 @@ class PlaybackStateManager private constructor() {
if (playbackState != null) {
logD("Found playback state $playbackState with queue size ${queueItems.size}")
unpackFromPlaybackState(playbackState)
unpackQueue(queueItems)
val musicStore = MusicStore.requireInstance()
unpackFromPlaybackState(playbackState, musicStore)
unpackQueue(queueItems, musicStore)
doParentSanityCheck()
}
@ -640,12 +639,12 @@ class PlaybackStateManager private constructor() {
/**
* Unpack a [playbackState] into this instance.
*/
private fun unpackFromPlaybackState(playbackState: DatabaseState) {
private fun unpackFromPlaybackState(playbackState: DatabaseState, musicStore: MusicStore) {
// Turn the simplified information from PlaybackState into usable data.
// Do queue setup first
mMode = PlaybackMode.fromInt(playbackState.mode) ?: PlaybackMode.ALL_SONGS
mParent = findParent(playbackState.parentHash, mMode)
mParent = findParent(playbackState.parentHash, mMode, musicStore)
mIndex = playbackState.index
// Then set up the current state
@ -663,7 +662,6 @@ class PlaybackStateManager private constructor() {
*/
private fun packQueue(): List<DatabaseQueueItem> {
val unified = mutableListOf<DatabaseQueueItem>()
var queueItemId = 0L
mUserQueue.forEach { song ->
@ -683,7 +681,7 @@ class PlaybackStateManager private constructor() {
* Unpack a list of queue items into a queue & user queue.
* @param queueItems The list of [DatabaseQueueItem]s to unpack.
*/
private fun unpackQueue(queueItems: List<DatabaseQueueItem>) {
private fun unpackQueue(queueItems: List<DatabaseQueueItem>, musicStore: MusicStore) {
for (item in queueItems) {
musicStore.findSongFast(item.songHash, item.albumHash)?.let { song ->
if (item.isUserQueue) {
@ -711,7 +709,7 @@ class PlaybackStateManager private constructor() {
/**
* Get a [Parent] from music store given a [hash] and PlaybackMode [mode].
*/
private fun findParent(hash: Int, mode: PlaybackMode): Parent? {
private fun findParent(hash: Int, mode: PlaybackMode, musicStore: MusicStore): Parent? {
return when (mode) {
PlaybackMode.IN_GENRE -> musicStore.genres.find { it.hash == hash }
PlaybackMode.IN_ARTIST -> musicStore.artists.find { it.hash == hash }
@ -737,36 +735,6 @@ class PlaybackStateManager private constructor() {
}
}
// --- ORDERING FUNCTIONS ---
/**
* Create an ordered queue based on the main list of songs
*/
private fun orderSongs(): MutableList<Song> {
return settingsManager.libSongSort.sortSongs(musicStore.songs).toMutableList()
}
/**
* Create an ordered queue based on an [Album].
*/
private fun orderSongsInAlbum(album: Album): MutableList<Song> {
return settingsManager.detailAlbumSort.sortAlbum(album).toMutableList()
}
/**
* Create an ordered queue based on an [Artist].
*/
private fun orderSongsInArtist(artist: Artist): MutableList<Song> {
return settingsManager.detailArtistSort.sortArtist(artist).toMutableList()
}
/**
* Create an ordered queue based on a [Genre].
*/
private fun orderSongsInGenre(genre: Genre): MutableList<Song> {
return settingsManager.detailGenreSort.sortGenre(genre).toMutableList()
}
/**
* The interface for receiving updates from [PlaybackStateManager].
* Add the callback to [PlaybackStateManager] using [addCallback],
@ -796,7 +764,7 @@ class PlaybackStateManager private constructor() {
/**
* Get/Instantiate the single instance of [PlaybackStateManager].
*/
fun getInstance(): PlaybackStateManager {
fun maybeGetInstance(): PlaybackStateManager {
val currentInstance = INSTANCE
if (currentInstance != null) {

View file

@ -37,7 +37,7 @@ class AudioReactor(
context: Context,
private val player: SimpleExoPlayer
) : AudioManager.OnAudioFocusChangeListener {
private val playbackManager = PlaybackStateManager.getInstance()
private val playbackManager = PlaybackStateManager.maybeGetInstance()
private val settingsManager = SettingsManager.getInstance()
private val audioManager = context.getSystemServiceSafe(AudioManager::class)

View file

@ -92,7 +92,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
private val systemReceiver = SystemEventReceiver()
// Managers
private val playbackManager = PlaybackStateManager.getInstance()
private val playbackManager = PlaybackStateManager.maybeGetInstance()
private val settingsManager = SettingsManager.getInstance()
// State

View file

@ -39,7 +39,7 @@ class PlaybackSessionConnector(
private val player: Player,
private val mediaSession: MediaSessionCompat
) : PlaybackStateManager.Callback, Player.Listener, MediaSessionCompat.Callback() {
private val playbackManager = PlaybackStateManager.getInstance()
private val playbackManager = PlaybackStateManager.maybeGetInstance()
private val emptyMetadata = MediaMetadataCompat.Builder().build()
init {

View file

@ -48,7 +48,6 @@ class SearchViewModel : ViewModel() {
val isNavigating: Boolean get() = mIsNavigating
val filterMode: DisplayMode? get() = mFilterMode
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
init {
@ -60,9 +59,10 @@ class SearchViewModel : ViewModel() {
* Will push results to [searchResults].
*/
fun doSearch(query: String, context: Context) {
val musicStore = MusicStore.maybeGetInstance()
mLastQuery = query
if (query.isEmpty()) {
if (query.isEmpty() || musicStore == null) {
mSearchResults.value = listOf()
return

View file

@ -29,11 +29,13 @@ import android.view.ViewGroup
import androidx.core.net.toUri
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentAboutBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.logD
@ -44,13 +46,14 @@ import org.oxycblt.auxio.util.showToast
* @author OxygenCobalt
*/
class AboutFragment : Fragment() {
private val homeModel: HomeViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentAboutBinding.inflate(layoutInflater)
val musicStore = MusicStore.getInstance()
binding.applyEdge { bars ->
binding.aboutAppbar.updatePadding(top = bars.top)
@ -65,9 +68,17 @@ class AboutFragment : Fragment() {
binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_CODEBASE) }
binding.aboutFaq.setOnClickListener { openLinkInBrowser(LINK_FAQ) }
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
homeModel.loaderResponse.observe(viewLifecycleOwner) { response ->
val count = when (response) {
is MusicStore.Response.Ok -> response.musicStore.songs.size
else -> 0
}
binding.aboutSongCount.text = getString(
R.string.fmt_songs_loaded, musicStore.songs.size
R.string.fmt_songs_loaded, count
)
}
logD("Dialog created.")

View file

@ -191,10 +191,12 @@ fun View.applyEdge(onApply: (Rect) -> Unit) {
* Stopgap measure to make edge-to-edge work on views that also have a playback bar.
* The issue is that while we can apply padding initially, the padding will still be applied
* when the bar is shown, which is very ungood. We mitigate this by just checking the song state
* and removing the padding if it isnt available, which works okayish. I think Material Files has
* a better implementation of the same fix however, so once I'm able to hack that layout into
* Auxio things should be better.
* TODO: Get rid of this get rid of this get rid of this
* and removing the padding if there is one, which is a stupidly fragile band-aid but it
* works.
*
* TODO: Dumpster this and replace it with a dedicated layout. Only issue with that is how
* nested our layouts are, which basically forces us to do recursion magic. Hai Zhang's Material
* Files layout may help in this task.
*/
fun View.applyEdgeRespectingBar(
playbackModel: PlaybackViewModel,

View file

@ -33,7 +33,7 @@ import org.oxycblt.auxio.settings.SettingsManager
class WidgetController(private val context: Context) :
PlaybackStateManager.Callback,
SettingsManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance()
private val playbackManager = PlaybackStateManager.maybeGetInstance()
private val settingsManager = SettingsManager.getInstance()
private val widget = WidgetProvider()

View file

@ -55,6 +55,7 @@
android:layout_margin="@dimen/spacing_medium"
android:contentDescription="@string/desc_shuffle_all"
app:layout_anchor="@id/home_pager"
app:tint="?attr/colorControlNormal"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
app:layout_anchorGravity="bottom|end" />

View file

@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="loadingModel"
type="org.oxycblt.auxio.loading.LoadingViewModel" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/loading_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_large"
android:animateLayoutChanges="true"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<ProgressBar
android:id="@+id/loading_circle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@+id/loading_action_button"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/loading_error_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_small"
android:fontFamily="@font/inter_semibold"
android:textAlignment="center"
android:textColor="?android:attr/textColorPrimary"
android:textSize="@dimen/text_size_medium"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@+id/loading_action_button"
tools:text="No Music Found" />
<com.google.android.material.button.MaterialButton
android:id="@+id/loading_action_button"
style="@style/Widget.Auxio.Button.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/spacing_insane"
android:paddingEnd="@dimen/spacing_insane"
android:text="@string/lbl_retry"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</layout>

View file

@ -2,23 +2,7 @@
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
app:startDestination="@id/loading_fragment">
<fragment
android:id="@+id/loading_fragment"
android:name="org.oxycblt.auxio.loading.LoadingFragment"
android:label="LoadingFragment"
tools:layout="@layout/fragment_loading">
<action
android:id="@+id/action_to_main"
app:destination="@id/main_fragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:launchSingleTop="true"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:popUpTo="@id/loading_fragment"
app:popUpToInclusive="true" />
</fragment>
app:startDestination="@id/main_fragment">
<fragment
android:id="@+id/main_fragment"
android:name="org.oxycblt.auxio.MainFragment"