music: change loading ux
Move the main loading response code to MainFragment and add a new method for other objects to be notified of the progress of the music loading process. There's probably a better way to do this, but kotlin coroutines are so complex that I don't know where I would start. This also adds some enhancements, such as the error message now showing in more parts of the app and SearchFragment now re-running the query if the MusicStore instance is loaded.
This commit is contained in:
parent
255154c411
commit
51ba72d861
11 changed files with 243 additions and 153 deletions
|
@ -18,15 +18,22 @@
|
|||
|
||||
package org.oxycblt.auxio
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.applyEdge
|
||||
import org.oxycblt.auxio.util.applyMaterialDrawable
|
||||
|
@ -38,6 +45,7 @@ import org.oxycblt.auxio.util.logD
|
|||
*/
|
||||
class MainFragment : Fragment() {
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
|
@ -46,6 +54,13 @@ class MainFragment : Fragment() {
|
|||
): View {
|
||||
val binding = FragmentMainBinding.inflate(inflater)
|
||||
|
||||
// Build the permission launcher here as you can only do it in onCreateView/onCreate
|
||||
val permLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) {
|
||||
musicModel.reloadMusic(requireContext())
|
||||
}
|
||||
|
||||
// --- UI SETUP ---
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
@ -58,6 +73,10 @@ class MainFragment : Fragment() {
|
|||
|
||||
// --- 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.
|
||||
musicModel.loadMusic(requireContext())
|
||||
|
||||
// Change CompactPlaybackFragment's visibility here so that an animation occurs.
|
||||
binding.mainPlayback.isVisible = playbackModel.song.value != null
|
||||
|
||||
|
@ -65,6 +84,53 @@ class MainFragment : Fragment() {
|
|||
binding.mainPlayback.isVisible = song != null
|
||||
}
|
||||
|
||||
// Handle the music loader response.
|
||||
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
|
||||
// Handle the loader response.
|
||||
when (response) {
|
||||
// OK, start restoring playback now
|
||||
is MusicStore.Response.Ok -> playbackModel.setupPlayback(requireContext())
|
||||
|
||||
// Error, show the error to the user
|
||||
is MusicStore.Response.Err -> {
|
||||
logD("Received Error")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
when (response.kind) {
|
||||
MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> {
|
||||
snackbar.setAction(R.string.lbl_retry) {
|
||||
musicModel.reloadMusic(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
MusicStore.ErrorKind.NO_PERMS -> {
|
||||
snackbar.setAction(R.string.lbl_grant) {
|
||||
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logD("Fragment Created.")
|
||||
|
||||
return binding.root
|
||||
|
|
|
@ -18,16 +18,12 @@
|
|||
|
||||
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
|
||||
|
@ -38,7 +34,6 @@ 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
|
||||
|
@ -52,6 +47,7 @@ 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.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
|
@ -69,6 +65,7 @@ class HomeFragment : Fragment() {
|
|||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
|
@ -79,13 +76,6 @@ 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
|
||||
|
@ -218,84 +208,25 @@ class HomeFragment : Fragment() {
|
|||
|
||||
// --- 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)
|
||||
|
||||
// TODO: We actually have to move this to MainFragment. This also means we have to
|
||||
// have more than one thing watching the coroutine, which completely breaks what I
|
||||
// wanted to do.
|
||||
homeModel.loaderResponse.observe(viewLifecycleOwner) { response ->
|
||||
musicModel.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()
|
||||
}
|
||||
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 loading is impossible. PlaybackStateManager also relies on this
|
||||
// invariant, so please don't change it.
|
||||
null -> binding.homeFab.hide()
|
||||
else -> 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) {
|
||||
if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) {
|
||||
return@observe
|
||||
}
|
||||
|
||||
|
|
|
@ -18,12 +18,9 @@
|
|||
|
||||
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
|
||||
|
@ -38,7 +35,7 @@ import org.oxycblt.auxio.ui.SortMode
|
|||
* The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class HomeViewModel : ViewModel(), SettingsManager.Callback {
|
||||
class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCallback {
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
private val mSongs = MutableLiveData(listOf<Song>())
|
||||
|
@ -74,48 +71,9 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadMusic(context: Context) {
|
||||
mLoaderResponse.value = null
|
||||
|
||||
loadMusic(context)
|
||||
MusicStore.awaitInstance(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -178,8 +136,16 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
|
|||
mRecreateTabs.value = true
|
||||
}
|
||||
|
||||
override fun onLoaded(musicStore: 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)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
settingsManager.removeCallback(this)
|
||||
MusicStore.cancelAwaitInstance(this)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -129,9 +129,14 @@ class MusicStore private constructor() {
|
|||
NO_PERMS, NO_MUSIC, FAILED
|
||||
}
|
||||
|
||||
interface MusicCallback {
|
||||
fun onLoaded(musicStore: MusicStore)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: Response? = null
|
||||
private var RESPONSE: Response? = null
|
||||
private val AWAITING = mutableListOf<MusicCallback>()
|
||||
|
||||
/**
|
||||
* Initialize the loading process for this instance. This must be ran on a background
|
||||
|
@ -139,21 +144,53 @@ class MusicStore private constructor() {
|
|||
* immediately.
|
||||
*/
|
||||
suspend fun initInstance(context: Context): Response {
|
||||
val currentInstance = INSTANCE
|
||||
val currentInstance = RESPONSE
|
||||
|
||||
if (currentInstance is Response.Ok) {
|
||||
return currentInstance
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val result = MusicStore().load(context)
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
val response = MusicStore().load(context)
|
||||
|
||||
synchronized(this) {
|
||||
INSTANCE = result
|
||||
RESPONSE = response
|
||||
}
|
||||
|
||||
result
|
||||
response
|
||||
}
|
||||
|
||||
if (response is Response.Ok) {
|
||||
AWAITING.forEach { it.onLoaded(response.musicStore) }
|
||||
AWAITING.clear()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Await the successful creation of a [MusicStore] instance. The [callback]
|
||||
* will be called if the instance is already loaded. It's recommended to call
|
||||
* [cancelAwaitInstance] if the object is about to be destroyed to prevent any
|
||||
* memory leaks.
|
||||
*/
|
||||
fun awaitInstance(callback: MusicCallback) {
|
||||
// FIXME: There has to be some coroutiney way to do this instead of just making
|
||||
// a leak-prone callback system
|
||||
val currentInstance = maybeGetInstance()
|
||||
|
||||
if (currentInstance != null) {
|
||||
callback.onLoaded(currentInstance)
|
||||
}
|
||||
|
||||
AWAITING.add(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a callback from the queue.
|
||||
*/
|
||||
fun cancelAwaitInstance(callback: MusicCallback) {
|
||||
AWAITING.remove(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -163,7 +200,7 @@ class MusicStore private constructor() {
|
|||
* encountered an error. An instance is returned otherwise.
|
||||
*/
|
||||
fun maybeGetInstance(): MusicStore? {
|
||||
val currentInstance = INSTANCE
|
||||
val currentInstance = RESPONSE
|
||||
|
||||
return if (currentInstance is Response.Ok) {
|
||||
currentInstance.musicStore
|
||||
|
|
59
app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
Normal file
59
app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
Normal file
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* MusicViewModel.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.music
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MusicViewModel : ViewModel() {
|
||||
private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null)
|
||||
val loaderResponse: LiveData<MusicStore.Response?> = mLoaderResponse
|
||||
|
||||
private var isBusy = false
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadMusic(context: Context) {
|
||||
mLoaderResponse.value = null
|
||||
|
||||
loadMusic(context)
|
||||
}
|
||||
}
|
|
@ -54,7 +54,10 @@ import org.oxycblt.auxio.util.logD
|
|||
*/
|
||||
class SearchFragment : Fragment() {
|
||||
// SearchViewModel is only scoped to this Fragment
|
||||
private val searchModel: SearchViewModel by viewModels()
|
||||
private val searchModel: SearchViewModel by viewModels {
|
||||
SearchViewModel.Factory(requireContext())
|
||||
}
|
||||
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
|
@ -99,7 +102,7 @@ class SearchFragment : Fragment() {
|
|||
|
||||
setOnMenuItemClickListener { item ->
|
||||
if (item.itemId != R.id.submenu_filtering) {
|
||||
searchModel.updateFilterModeWithId(item.itemId, requireContext())
|
||||
searchModel.updateFilterModeWithId(item.itemId)
|
||||
item.isChecked = true
|
||||
true
|
||||
} else {
|
||||
|
@ -111,7 +114,7 @@ class SearchFragment : Fragment() {
|
|||
binding.searchEditText.apply {
|
||||
addTextChangedListener { text ->
|
||||
// Run the search with the updated text as the query
|
||||
searchModel.doSearch(text?.toString() ?: "", requireContext())
|
||||
searchModel.doSearch(text?.toString() ?: "")
|
||||
}
|
||||
|
||||
// Auto-open the keyboard when this view is shown
|
||||
|
@ -121,6 +124,7 @@ class SearchFragment : Fragment() {
|
|||
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
|
||||
binding.searchRecycler.apply {
|
||||
adapter = searchAdapter
|
||||
|
||||
|
@ -140,7 +144,6 @@ class SearchFragment : Fragment() {
|
|||
}
|
||||
|
||||
if (results.isEmpty()) {
|
||||
binding.searchAppbar.setExpanded(true)
|
||||
binding.searchRecycler.visibility = View.INVISIBLE
|
||||
} else {
|
||||
binding.searchRecycler.visibility = View.VISIBLE
|
||||
|
|
|
@ -23,6 +23,7 @@ import androidx.annotation.IdRes
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.R
|
||||
|
@ -37,7 +38,7 @@ import java.text.Normalizer
|
|||
* The [ViewModel] for the search functionality
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class SearchViewModel : ViewModel() {
|
||||
class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback {
|
||||
private val mSearchResults = MutableLiveData(listOf<BaseModel>())
|
||||
private var mIsNavigating = false
|
||||
private var mFilterMode: DisplayMode? = null
|
||||
|
@ -50,15 +51,22 @@ class SearchViewModel : ViewModel() {
|
|||
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
private val songHeader = Header(id = -1, context.getString(R.string.lbl_songs))
|
||||
private val albumHeader = Header(id = -1, context.getString(R.string.lbl_albums))
|
||||
private val artistHeader = Header(id = -1, context.getString(R.string.lbl_artists))
|
||||
private val genreHeader = Header(id = -1, context.getString(R.string.lbl_genres))
|
||||
|
||||
init {
|
||||
mFilterMode = settingsManager.searchFilterMode
|
||||
|
||||
MusicStore.awaitInstance(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use [query] to perform a search of the music library.
|
||||
* Will push results to [searchResults].
|
||||
*/
|
||||
fun doSearch(query: String, context: Context) {
|
||||
fun doSearch(query: String) {
|
||||
val musicStore = MusicStore.maybeGetInstance()
|
||||
mLastQuery = query
|
||||
|
||||
|
@ -75,28 +83,28 @@ class SearchViewModel : ViewModel() {
|
|||
|
||||
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) {
|
||||
musicStore.artists.filterByOrNull(query)?.let { artists ->
|
||||
results.add(Header(id = -1, name = context.getString(R.string.lbl_artists)))
|
||||
results.add(artistHeader)
|
||||
results.addAll(artists)
|
||||
}
|
||||
}
|
||||
|
||||
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) {
|
||||
musicStore.albums.filterByOrNull(query)?.let { albums ->
|
||||
results.add(Header(id = -2, name = context.getString(R.string.lbl_albums)))
|
||||
results.add(albumHeader)
|
||||
results.addAll(albums)
|
||||
}
|
||||
}
|
||||
|
||||
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) {
|
||||
musicStore.genres.filterByOrNull(query)?.let { genres ->
|
||||
results.add(Header(id = -3, name = context.getString(R.string.lbl_genres)))
|
||||
results.add(genreHeader)
|
||||
results.addAll(genres)
|
||||
}
|
||||
}
|
||||
|
||||
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) {
|
||||
musicStore.songs.filterByOrNull(query)?.let { songs ->
|
||||
results.add(Header(id = -4, name = context.getString(R.string.lbl_songs)))
|
||||
results.add(songHeader)
|
||||
results.addAll(songs)
|
||||
}
|
||||
}
|
||||
|
@ -109,7 +117,7 @@ class SearchViewModel : ViewModel() {
|
|||
* Update the current filter mode with a menu [id].
|
||||
* New value will be pushed to [filterMode].
|
||||
*/
|
||||
fun updateFilterModeWithId(@IdRes id: Int, context: Context) {
|
||||
fun updateFilterModeWithId(@IdRes id: Int) {
|
||||
mFilterMode = when (id) {
|
||||
R.id.option_filter_songs -> DisplayMode.SHOW_SONGS
|
||||
R.id.option_filter_albums -> DisplayMode.SHOW_ALBUMS
|
||||
|
@ -121,7 +129,7 @@ class SearchViewModel : ViewModel() {
|
|||
|
||||
settingsManager.searchFilterMode = mFilterMode
|
||||
|
||||
doSearch(mLastQuery, context)
|
||||
doSearch(mLastQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -159,7 +167,8 @@ class SearchViewModel : ViewModel() {
|
|||
idx += Character.charCount(cp)
|
||||
|
||||
when (Character.getType(cp)) {
|
||||
// Character.NON_SPACING_MARK and Character.COMBINING_SPACING_MARK
|
||||
// Character.NON_SPACING_MARK and Character.COMBINING_SPACING_MARK were added
|
||||
// by normalizer
|
||||
6, 8 -> continue
|
||||
|
||||
else -> sb.appendCodePoint(cp)
|
||||
|
@ -175,4 +184,26 @@ class SearchViewModel : ViewModel() {
|
|||
fun setNavigating(isNavigating: Boolean) {
|
||||
mIsNavigating = isNavigating
|
||||
}
|
||||
|
||||
// --- OVERRIDES ---
|
||||
|
||||
override fun onLoaded(musicStore: MusicStore) {
|
||||
doSearch(mLastQuery)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
MusicStore.cancelAwaitInstance(this)
|
||||
}
|
||||
|
||||
class Factory(private val context: Context) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
check(modelClass.isAssignableFrom(SearchViewModel::class.java)) {
|
||||
"SearchViewModel.Factory does not support this class"
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return SearchViewModel(context) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,6 @@ 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
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
@ -69,14 +68,9 @@ class AboutFragment : Fragment() {
|
|||
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
|
||||
}
|
||||
|
||||
homeModel.songs.observe(viewLifecycleOwner) { songs ->
|
||||
binding.aboutSongCount.text = getString(
|
||||
R.string.fmt_songs_loaded, count
|
||||
R.string.fmt_songs_loaded, songs.size
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -81,9 +81,9 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
// Map each widget form to the cells where it would look at least okay.
|
||||
val views = mapOf(
|
||||
SizeF(180f, 152f) to createMinimalWidget(context, state),
|
||||
SizeF(250f, 152f) to createCompactWidget(context, state),
|
||||
SizeF(272f, 152f) to createCompactWidget(context, state),
|
||||
SizeF(180f, 270f) to createSmallWidget(context, state),
|
||||
SizeF(250f, 270f) to createFullWidget(context, state)
|
||||
SizeF(272f, 270f) to createFullWidget(context, state)
|
||||
)
|
||||
|
||||
appWidgetManager.applyViewsCompat(context, views)
|
||||
|
|
|
@ -2,11 +2,14 @@
|
|||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/info_widget_desc"
|
||||
android:initialLayout="@layout/widget_small"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeWidth="176dp"
|
||||
android:minResizeHeight="152dp"
|
||||
android:previewLayout="@layout/widget_small"
|
||||
android:previewImage="@drawable/ui_widget_preview"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:minWidth="180dp"
|
||||
android:minWidth="176dp"
|
||||
android:minHeight="180dp"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="3"
|
||||
android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen" />
|
|
@ -1,9 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/widget_small"
|
||||
android:minWidth="180dp"
|
||||
android:minWidth="176dp"
|
||||
android:minHeight="180dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeWidth="176dp"
|
||||
android:minResizeHeight="152dp"
|
||||
android:previewImage="@drawable/ui_widget_preview"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
|
|
Loading…
Reference in a new issue