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:
OxygenCobalt 2021-10-27 18:23:15 -06:00
parent 255154c411
commit 51ba72d861
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 243 additions and 153 deletions

View file

@ -18,15 +18,22 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.Manifest
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 android.view.ViewGroup 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.isVisible
import androidx.core.view.updatePadding 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 com.google.android.material.snackbar.Snackbar
import org.oxycblt.auxio.databinding.FragmentMainBinding 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.playback.PlaybackViewModel
import org.oxycblt.auxio.util.applyEdge import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.applyMaterialDrawable import org.oxycblt.auxio.util.applyMaterialDrawable
@ -38,6 +45,7 @@ import org.oxycblt.auxio.util.logD
*/ */
class MainFragment : Fragment() { class MainFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -46,6 +54,13 @@ class MainFragment : Fragment() {
): View { ): View {
val binding = FragmentMainBinding.inflate(inflater) 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 --- // --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
@ -58,6 +73,10 @@ class MainFragment : Fragment() {
// --- VIEWMODEL SETUP --- // --- 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. // Change CompactPlaybackFragment's visibility here so that an animation occurs.
binding.mainPlayback.isVisible = playbackModel.song.value != null binding.mainPlayback.isVisible = playbackModel.song.value != null
@ -65,6 +84,53 @@ class MainFragment : Fragment() {
binding.mainPlayback.isVisible = song != null 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.") logD("Fragment Created.")
return binding.root return binding.root

View file

@ -18,16 +18,12 @@
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 android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button
import androidx.activity.result.contract.ActivityResultContracts
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.core.view.updatePaddingRelative import androidx.core.view.updatePaddingRelative
@ -38,7 +34,6 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R 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.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
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.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
@ -69,6 +65,7 @@ class HomeFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -79,13 +76,6 @@ class HomeFragment : Fragment() {
var bottomPadding = 0 var bottomPadding = 0
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()
) {
homeModel.reloadMusic(requireContext())
}
// --- UI SETUP --- // --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
@ -218,84 +208,25 @@ class HomeFragment : Fragment() {
// --- VIEWMODEL SETUP --- // --- 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. // There is no way a fast scrolling event can continue across a re-create. Reset it.
homeModel.updateFastScrolling(false) homeModel.updateFastScrolling(false)
// TODO: We actually have to move this to MainFragment. This also means we have to musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
// have more than one thing watching the coroutine, which completely breaks what I
// wanted to do.
homeModel.loaderResponse.observe(viewLifecycleOwner) { response ->
// Handle the loader response. // Handle the loader response.
when (response) { when (response) {
is MusicStore.Response.Ok -> { is MusicStore.Response.Ok -> binding.homeFab.show()
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 // 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 // that any kind of loading is impossible. PlaybackStateManager also relies on this
// invariant, so please don't change it. // invariant, so please don't change it.
null -> binding.homeFab.hide() else -> binding.homeFab.hide()
} }
} }
homeModel.fastScrolling.observe(viewLifecycleOwner) { scrolling -> homeModel.fastScrolling.observe(viewLifecycleOwner) { scrolling ->
// Make sure an update here doesn't mess up the FAB state when it comes to the // Make sure an update here doesn't mess up the FAB state when it comes to the
// loader response. // loader response.
if (homeModel.loaderResponse.value !is MusicStore.Response.Ok) { if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) {
return@observe return@observe
} }

View file

@ -18,12 +18,9 @@
package org.oxycblt.auxio.home package org.oxycblt.auxio.home
import android.content.Context
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre 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. * The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class HomeViewModel : ViewModel(), SettingsManager.Callback { class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCallback {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private val mSongs = MutableLiveData(listOf<Song>()) private val mSongs = MutableLiveData(listOf<Song>())
@ -74,48 +71,9 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
private val mFastScrolling = MutableLiveData(false) private val mFastScrolling = MutableLiveData(false)
val fastScrolling: LiveData<Boolean> = mFastScrolling val fastScrolling: LiveData<Boolean> = mFastScrolling
private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null)
val loaderResponse: LiveData<MusicStore.Response?> = mLoaderResponse
private var isBusy = false
init { init {
settingsManager.addCallback(this) settingsManager.addCallback(this)
} MusicStore.awaitInstance(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)
} }
/** /**
@ -178,8 +136,16 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
mRecreateTabs.value = true 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() { override fun onCleared() {
super.onCleared() super.onCleared()
settingsManager.removeCallback(this) settingsManager.removeCallback(this)
MusicStore.cancelAwaitInstance(this)
} }
} }

View file

@ -129,9 +129,14 @@ class MusicStore private constructor() {
NO_PERMS, NO_MUSIC, FAILED NO_PERMS, NO_MUSIC, FAILED
} }
interface MusicCallback {
fun onLoaded(musicStore: MusicStore)
}
companion object { companion object {
@Volatile @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 * Initialize the loading process for this instance. This must be ran on a background
@ -139,21 +144,53 @@ class MusicStore private constructor() {
* immediately. * immediately.
*/ */
suspend fun initInstance(context: Context): Response { suspend fun initInstance(context: Context): Response {
val currentInstance = INSTANCE val currentInstance = RESPONSE
if (currentInstance is Response.Ok) { if (currentInstance is Response.Ok) {
return currentInstance return currentInstance
} }
return withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
val result = MusicStore().load(context) val response = MusicStore().load(context)
synchronized(this) { 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. * encountered an error. An instance is returned otherwise.
*/ */
fun maybeGetInstance(): MusicStore? { fun maybeGetInstance(): MusicStore? {
val currentInstance = INSTANCE val currentInstance = RESPONSE
return if (currentInstance is Response.Ok) { return if (currentInstance is Response.Ok) {
currentInstance.musicStore currentInstance.musicStore

View 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)
}
}

View file

@ -54,7 +54,10 @@ import org.oxycblt.auxio.util.logD
*/ */
class SearchFragment : Fragment() { class SearchFragment : Fragment() {
// SearchViewModel is only scoped to this 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 playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
@ -99,7 +102,7 @@ class SearchFragment : Fragment() {
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
if (item.itemId != R.id.submenu_filtering) { if (item.itemId != R.id.submenu_filtering) {
searchModel.updateFilterModeWithId(item.itemId, requireContext()) searchModel.updateFilterModeWithId(item.itemId)
item.isChecked = true item.isChecked = true
true true
} else { } else {
@ -111,7 +114,7 @@ class SearchFragment : Fragment() {
binding.searchEditText.apply { binding.searchEditText.apply {
addTextChangedListener { text -> addTextChangedListener { text ->
// Run the search with the updated text as the query // 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 // Auto-open the keyboard when this view is shown
@ -121,6 +124,7 @@ class SearchFragment : Fragment() {
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
} }
} }
binding.searchRecycler.apply { binding.searchRecycler.apply {
adapter = searchAdapter adapter = searchAdapter
@ -140,7 +144,6 @@ class SearchFragment : Fragment() {
} }
if (results.isEmpty()) { if (results.isEmpty()) {
binding.searchAppbar.setExpanded(true)
binding.searchRecycler.visibility = View.INVISIBLE binding.searchRecycler.visibility = View.INVISIBLE
} else { } else {
binding.searchRecycler.visibility = View.VISIBLE binding.searchRecycler.visibility = View.VISIBLE

View file

@ -23,6 +23,7 @@ import androidx.annotation.IdRes
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -37,7 +38,7 @@ import java.text.Normalizer
* The [ViewModel] for the search functionality * The [ViewModel] for the search functionality
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SearchViewModel : ViewModel() { class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback {
private val mSearchResults = MutableLiveData(listOf<BaseModel>()) private val mSearchResults = MutableLiveData(listOf<BaseModel>())
private var mIsNavigating = false private var mIsNavigating = false
private var mFilterMode: DisplayMode? = null private var mFilterMode: DisplayMode? = null
@ -50,15 +51,22 @@ class SearchViewModel : ViewModel() {
private val settingsManager = SettingsManager.getInstance() 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 { init {
mFilterMode = settingsManager.searchFilterMode mFilterMode = settingsManager.searchFilterMode
MusicStore.awaitInstance(this)
} }
/** /**
* Use [query] to perform a search of the music library. * Use [query] to perform a search of the music library.
* Will push results to [searchResults]. * Will push results to [searchResults].
*/ */
fun doSearch(query: String, context: Context) { fun doSearch(query: String) {
val musicStore = MusicStore.maybeGetInstance() val musicStore = MusicStore.maybeGetInstance()
mLastQuery = query mLastQuery = query
@ -75,28 +83,28 @@ class SearchViewModel : ViewModel() {
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) {
musicStore.artists.filterByOrNull(query)?.let { 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) results.addAll(artists)
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) {
musicStore.albums.filterByOrNull(query)?.let { 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) results.addAll(albums)
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) {
musicStore.genres.filterByOrNull(query)?.let { 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) results.addAll(genres)
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) {
musicStore.songs.filterByOrNull(query)?.let { 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) results.addAll(songs)
} }
} }
@ -109,7 +117,7 @@ class SearchViewModel : ViewModel() {
* Update the current filter mode with a menu [id]. * Update the current filter mode with a menu [id].
* New value will be pushed to [filterMode]. * New value will be pushed to [filterMode].
*/ */
fun updateFilterModeWithId(@IdRes id: Int, context: Context) { fun updateFilterModeWithId(@IdRes id: Int) {
mFilterMode = when (id) { mFilterMode = when (id) {
R.id.option_filter_songs -> DisplayMode.SHOW_SONGS R.id.option_filter_songs -> DisplayMode.SHOW_SONGS
R.id.option_filter_albums -> DisplayMode.SHOW_ALBUMS R.id.option_filter_albums -> DisplayMode.SHOW_ALBUMS
@ -121,7 +129,7 @@ class SearchViewModel : ViewModel() {
settingsManager.searchFilterMode = mFilterMode settingsManager.searchFilterMode = mFilterMode
doSearch(mLastQuery, context) doSearch(mLastQuery)
} }
/** /**
@ -159,7 +167,8 @@ class SearchViewModel : ViewModel() {
idx += Character.charCount(cp) idx += Character.charCount(cp)
when (Character.getType(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 6, 8 -> continue
else -> sb.appendCodePoint(cp) else -> sb.appendCodePoint(cp)
@ -175,4 +184,26 @@ class SearchViewModel : ViewModel() {
fun setNavigating(isNavigating: Boolean) { fun setNavigating(isNavigating: Boolean) {
mIsNavigating = isNavigating 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
}
}
} }

View file

@ -36,7 +36,6 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentAboutBinding import org.oxycblt.auxio.databinding.FragmentAboutBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.util.applyEdge import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
@ -69,14 +68,9 @@ class AboutFragment : Fragment() {
binding.aboutFaq.setOnClickListener { openLinkInBrowser(LINK_FAQ) } binding.aboutFaq.setOnClickListener { openLinkInBrowser(LINK_FAQ) }
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) } binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
homeModel.loaderResponse.observe(viewLifecycleOwner) { response -> homeModel.songs.observe(viewLifecycleOwner) { songs ->
val count = when (response) {
is MusicStore.Response.Ok -> response.musicStore.songs.size
else -> 0
}
binding.aboutSongCount.text = getString( binding.aboutSongCount.text = getString(
R.string.fmt_songs_loaded, count R.string.fmt_songs_loaded, songs.size
) )
} }

View file

@ -81,9 +81,9 @@ class WidgetProvider : AppWidgetProvider() {
// Map each widget form to the cells where it would look at least okay. // Map each widget form to the cells where it would look at least okay.
val views = mapOf( val views = mapOf(
SizeF(180f, 152f) to createMinimalWidget(context, state), 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(180f, 270f) to createSmallWidget(context, state),
SizeF(250f, 270f) to createFullWidget(context, state) SizeF(272f, 270f) to createFullWidget(context, state)
) )
appWidgetManager.applyViewsCompat(context, views) appWidgetManager.applyViewsCompat(context, views)

View file

@ -2,11 +2,14 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/info_widget_desc" android:description="@string/info_widget_desc"
android:initialLayout="@layout/widget_small" android:initialLayout="@layout/widget_small"
android:minResizeWidth="180dp" android:minResizeWidth="176dp"
android:minResizeHeight="152dp" android:minResizeHeight="152dp"
android:previewLayout="@layout/widget_small" android:previewLayout="@layout/widget_small"
android:previewImage="@drawable/ui_widget_preview"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:minWidth="180dp" android:minWidth="176dp"
android:minHeight="180dp" android:minHeight="180dp"
android:targetCellWidth="3"
android:targetCellHeight="3"
android:updatePeriodMillis="0" android:updatePeriodMillis="0"
android:widgetCategory="home_screen" /> android:widgetCategory="home_screen" />

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_small" android:initialLayout="@layout/widget_small"
android:minWidth="180dp" android:minWidth="176dp"
android:minHeight="180dp" android:minHeight="180dp"
android:minResizeWidth="180dp" android:minResizeWidth="176dp"
android:minResizeHeight="152dp" android:minResizeHeight="152dp"
android:previewImage="@drawable/ui_widget_preview" android:previewImage="@drawable/ui_widget_preview"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"