diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7cd215cc8..26eacc16f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -75,8 +75,8 @@
-
+
@@ -116,7 +116,7 @@
-
+
(holder as GenreDetailViewHolder).bind(item, listener)
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
index fcc6e4220..59e5e5ced 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
@@ -148,8 +148,6 @@ class AlbumListFragment : ListFragment(), FastScrollRec
override val currentList: List-
get() = differ.currentList
- override fun getItemCount() = differ.currentList.size
-
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumViewHolder.new(parent)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
index 5be7b68b8..2663d5ba8 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
@@ -123,8 +123,6 @@ class ArtistListFragment : ListFragment(), FastScrollRe
override val currentList: List
-
get() = differ.currentList
- override fun getItemCount() = differ.currentList.size
-
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistViewHolder.new(parent)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
index 14b4a557d..0d085835f 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
@@ -122,8 +122,6 @@ class GenreListFragment : ListFragment(), FastScrollRec
override val currentList: List
-
get() = differ.currentList
- override fun getItemCount() = differ.currentList.size
-
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreViewHolder.new(parent)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
index 58cbb7bdc..212ab15ec 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
@@ -162,8 +162,6 @@ class SongListFragment : ListFragment(), FastScrollRecy
override val currentList: List
-
get() = differ.currentList
- override fun getItemCount() = differ.currentList.size
-
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.new(parent)
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt
index f5f24a6f0..fea140a44 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt
@@ -35,6 +35,14 @@ abstract class PlayingIndicatorAdapter : RecyclerV
private var currentItem: Item? = null
private var isPlaying = false
+ /**
+ * The current list of the adapter. This is used to update items if the indicator
+ * state changes.
+ */
+ abstract val currentList: List
-
+
+ override fun getItemCount() = currentList.size
+
override fun onBindViewHolder(holder: VH, position: Int, payloads: List) {
if (payloads.isEmpty()) {
// Not updating any indicator-specific things, so delegate to the concrete
@@ -47,13 +55,6 @@ abstract class PlayingIndicatorAdapter : RecyclerV
holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying)
}
}
-
- /**
- * The current list of the adapter. This is used to update items if the indicator
- * state changes.
- */
- abstract val currentList: List
-
-
/**
* Update the currently playing item in the list.
* @param item The item currently being played, or null if it is not being played.
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
index 0e79c70c3..9f166db7e 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
@@ -43,7 +43,6 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A [Fragment] that displays more information about the song, along with more media controls.
- * Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Make seek thumb grow when selected
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
index e8c802a99..ad076bd0c 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
@@ -27,11 +27,17 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
+/**
+ * An adapter that displays search results.
+ * @param listener An [ExtendedListListener] to bind interactions to.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
class SearchAdapter(private val listener: ExtendedListListener) :
SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup {
private val differ = AsyncListDiffer(this, DIFF_CALLBACK)
- override fun getItemCount() = differ.currentList.size
+ override val currentList: List
-
+ get() = differ.currentList
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
@@ -65,14 +71,18 @@ class SearchAdapter(private val listener: ExtendedListListener) :
override fun isItemFullWidth(position: Int) = differ.currentList[position] is Header
- override val currentList: List
-
- get() = differ.currentList
-
- fun submitList(list: List
- , callback: () -> Unit) {
- differ.submitList(list, callback)
+ /**
+ * Asynchronously update the list with new items. Assumes that the list only contains
+ * supported data..
+ * @param newList The new [Item]s for the adapter to display.
+ * @param callback A block called when the asynchronous update is completed.
+ */
+ fun submitList(newList: List
- , callback: () -> Unit) {
+ differ.submitList(newList, callback)
}
companion object {
+ /** A comparator that can be used with DiffUtil. */
private val DIFF_CALLBACK =
object : SimpleItemCallback
- () {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
index 89524073f..13de99c64 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
@@ -25,7 +25,6 @@ import android.view.inputmethod.InputMethodManager
import androidx.core.view.isInvisible
import androidx.core.view.postDelayed
import androidx.core.widget.addTextChangedListener
-import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.google.android.material.transition.MaterialSharedAxis
import org.oxycblt.auxio.R
@@ -43,21 +42,22 @@ import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.*
/**
- * A [Fragment] that allows for the searching of the entire music library. TODO: Minor rework with
- * better keyboard logic, recycler updating, and chips
+ * The [ListFragment] providing search functionality for the music library.
+ *
+ * TODO: Better keyboard management
+ *
+ * TODO: Multi-filtering with chips
+ *
* @author Alexander Capehart (OxygenCobalt)
*/
class SearchFragment : ListFragment() {
- // SearchViewModel is only scoped to this Fragment
private val searchModel: SearchViewModel by androidViewModels()
-
private val searchAdapter = SearchAdapter(this)
+ private var launchedKeyboard = false
private val imm: InputMethodManager by lifecycleObject { binding ->
binding.context.getSystemServiceCompat(InputMethodManager::class)
}
- private var launchedKeyboard = false
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
@@ -75,19 +75,11 @@ class SearchFragment : ListFragment() {
super.onBindingCreated(binding, savedInstanceState)
binding.searchToolbar.apply {
- val itemIdToSelect =
- when (searchModel.filterMode) {
- MusicMode.SONGS -> R.id.option_filter_songs
- MusicMode.ALBUMS -> R.id.option_filter_albums
- MusicMode.ARTISTS -> R.id.option_filter_artists
- MusicMode.GENRES -> R.id.option_filter_genres
- null -> R.id.option_filter_all
- }
-
- menu.findItem(itemIdToSelect).isChecked = true
+ // Initialize the current filtering mode.
+ menu.findItem(searchModel.getFilterOptionId()).isChecked = true
setNavigationOnClickListener {
- // Drop keyboard as it's no longer needed
+ // Keyboard is no longer needed, drop it.
imm.hide()
findNavController().navigateUp()
}
@@ -112,7 +104,7 @@ class SearchFragment : ListFragment() {
// --- VIEWMODEL SETUP ---
- collectImmediately(searchModel.searchResults, ::updateResults)
+ collectImmediately(searchModel.searchResults, ::updateSearchResults)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
@@ -134,7 +126,7 @@ class SearchFragment : ListFragment() {
if (item.itemId != R.id.submenu_filtering) {
// Is a change in filter mode and not just a junk submenu click, update
// the filtering within SearchViewModel.
- searchModel.updateFilterModeWithId(item.itemId)
+ searchModel.setFilterOptionId(item.itemId)
return true
}
@@ -148,7 +140,7 @@ class SearchFragment : ListFragment() {
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
- else -> error("Unexpected playback mode: ${mode}")
+ else -> error("Unexpected playback mode: $mode")
}
is MusicParent -> navModel.exploreNavigateTo(music)
}
@@ -164,17 +156,17 @@ class SearchFragment : ListFragment() {
}
}
- private fun updateResults(results: List
- ) {
+ private fun updateSearchResults(results: List
- ) {
val binding = requireBinding()
-
+ // Don't show the RecyclerView (and it's stray overscroll effects) when there
+ // are no results.
+ binding.searchRecycler.isInvisible = results.isEmpty()
searchAdapter.submitList(results.toMutableList()) {
// I would make it so that the position is only scrolled back to the top when
// the query actually changes instead of once every re-creation event, but sadly
// that doesn't seem possible.
binding.searchRecycler.scrollToPosition(0)
}
-
- binding.searchRecycler.isInvisible = results.isEmpty()
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
@@ -204,6 +196,10 @@ class SearchFragment : ListFragment() {
}
}
+ /**
+ * Safely focus the keyboard on a particular [View].
+ * @param view The [View] to focus the keyboard on.
+ */
private fun InputMethodManager.show(view: View) {
view.apply {
requestFocus()
@@ -211,6 +207,9 @@ class SearchFragment : ListFragment() {
}
}
+ /**
+ * Safely hide the keyboard from this view.
+ */
private fun InputMethodManager.hide() {
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
index 6b6b09d95..64725a723 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
@@ -18,7 +18,6 @@
package org.oxycblt.auxio.search
import android.app.Application
-import android.content.Context
import androidx.annotation.IdRes
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
@@ -45,153 +44,187 @@ import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
/**
- * The [ViewModel] for search functionality.
+ * An [AndroidViewModel] that keeps performs search operations and tracks their results.
* @author Alexander Capehart (OxygenCobalt)
*/
class SearchViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
- private val settings = Settings(application)
-
- private val _searchResults = MutableStateFlow(listOf
- ())
-
- /** Current search results from the last [search] call. */
- val searchResults: StateFlow
>
- get() = _searchResults
-
- val filterMode: MusicMode?
- get() = settings.searchFilterMode
-
+ private val settings = Settings(context)
private var lastQuery: String? = null
private var currentSearchJob: Job? = null
+ private val _searchResults = MutableStateFlow(listOf- ())
+ /** The results of the last [search] call, if any. */
+ val searchResults: StateFlow
>
+ get() = _searchResults
+
init {
musicStore.addCallback(this)
}
- /**
- * Use [query] to perform a search of the music library. Will push results to [searchResults].
- */
- fun search(query: String?) {
- lastQuery = query
-
- currentSearchJob?.cancel()
-
- val library = musicStore.library
- if (query.isNullOrEmpty() || library == null) {
- logD("No music/query, ignoring search")
- _searchResults.value = listOf()
- return
- }
-
- logD("Performing search for $query")
-
- // Searching can be quite expensive, so get on a co-routine
- currentSearchJob =
- viewModelScope.launch {
- val sort = Sort(Sort.Mode.ByName, true)
- val results = mutableListOf- ()
-
- // Note: a filter mode of null means to not filter at all.
-
- if (filterMode == null || filterMode == MusicMode.ARTISTS) {
- library.artists.filterArtistsBy(query)?.let { artists ->
- results.add(Header(R.string.lbl_artists))
- results.addAll(sort.artists(artists))
- }
- }
-
- if (filterMode == null || filterMode == MusicMode.ALBUMS) {
- library.albums.filterAlbumsBy(query)?.let { albums ->
- results.add(Header(R.string.lbl_albums))
- results.addAll(sort.albums(albums))
- }
- }
-
- if (filterMode == null || filterMode == MusicMode.GENRES) {
- library.genres.filterGenresBy(query)?.let { genres ->
- results.add(Header(R.string.lbl_genres))
- results.addAll(sort.genres(genres))
- }
- }
-
- if (filterMode == null || filterMode == MusicMode.SONGS) {
- library.songs.filterSongsBy(query)?.let { songs ->
- results.add(Header(R.string.lbl_songs))
- results.addAll(sort.songs(songs))
- }
- }
-
- yield()
- _searchResults.value = results
- }
- }
-
- /**
- * Update the current filter mode with a menu [id]. New value will be pushed to [filterMode].
- */
- fun updateFilterModeWithId(@IdRes id: Int) {
- val newFilterMode =
- when (id) {
- R.id.option_filter_songs -> MusicMode.SONGS
- R.id.option_filter_albums -> MusicMode.ALBUMS
- R.id.option_filter_artists -> MusicMode.ARTISTS
- R.id.option_filter_genres -> MusicMode.GENRES
- else -> null
- }
-
- logD("Updating filter mode to $newFilterMode")
-
- settings.searchFilterMode = newFilterMode
-
- search(lastQuery)
- }
-
- private fun List.filterSongsBy(value: String) =
- baseFilterBy(value) {
- it.rawSortName?.contains(value, ignoreCase = true) == true ||
- it.path.name.contains(value)
- }
-
- private fun List.filterAlbumsBy(value: String) =
- baseFilterBy(value) { it.rawSortName?.contains(value, ignoreCase = true) == true }
-
- private fun List.filterArtistsBy(value: String) =
- baseFilterBy(value) { it.rawSortName?.contains(value, ignoreCase = true) == true }
-
- private fun List.filterGenresBy(value: String) = baseFilterBy(value) { false }
-
- private inline fun List.baseFilterBy(value: String, fallback: (T) -> Boolean) =
- filter {
- // The basic comparison is first by the *normalized* name, as that allows a
- // non-unicode search to match with some unicode characters. In an ideal world, we
- // would just want to leverage CollationKey, but that is not designed for a contains
- // algorithm. If that fails, filter impls have fallback values, primarily around
- // sort tags or file names.
- it.resolveNameNormalized(context).contains(value, ignoreCase = true) ||
- fallback(it)
- }
- .ifEmpty { null }
-
- private fun Music.resolveNameNormalized(context: Context): String {
- val norm = Normalizer.normalize(resolveName(context), Normalizer.Form.NFKD)
- return NORMALIZATION_SANITIZE_REGEX.replace(norm, "")
- }
-
- override fun onLibraryChanged(library: MusicStore.Library?) {
- if (library != null) {
- logD("Library changed, re-searching")
- // Make sure our query is up to date with the music library.
- search(lastQuery)
- }
- }
-
override fun onCleared() {
super.onCleared()
musicStore.removeCallback(this)
}
+ override fun onLibraryChanged(library: MusicStore.Library?) {
+ if (library != null) {
+ // Make sure our query is up to date with the music library.
+ search(lastQuery)
+ }
+ }
+
+ /**
+ * Use [query] to perform a search of the music library. Will push results to [searchResults].
+ */
+ fun search(query: String?) {
+ // Cancel the previous background search.
+ currentSearchJob?.cancel()
+ lastQuery = query
+
+ val library = musicStore.library
+ if (query.isNullOrEmpty() || library == null) {
+ logD("Search query is not applicable.")
+ _searchResults.value = listOf()
+ return
+ }
+
+ logD("Searching music library for $query")
+
+ // Searching is time-consuming, so do it in the background.
+ currentSearchJob =
+ viewModelScope.launch {
+ _searchResults.value = searchImpl(library, query).also { yield() }
+ }
+ }
+
+ private fun searchImpl(library: MusicStore.Library, query: String): List
- {
+ val sort = Sort(Sort.Mode.ByName, true)
+ val filterMode = settings.searchFilterMode
+ val results = mutableListOf
- ()
+
+ // Note: A null filter mode maps to the "All" filter option, hence the check.
+
+ if (filterMode == null || filterMode == MusicMode.ARTISTS) {
+ library.artists.filterArtistsBy(query)?.let { artists ->
+ results.add(Header(R.string.lbl_artists))
+ results.addAll(sort.artists(artists))
+ }
+ }
+
+ if (filterMode == null || filterMode == MusicMode.ALBUMS) {
+ library.albums.filterAlbumsBy(query)?.let { albums ->
+ results.add(Header(R.string.lbl_albums))
+ results.addAll(sort.albums(albums))
+ }
+ }
+
+ if (filterMode == null || filterMode == MusicMode.GENRES) {
+ library.genres.filterGenresBy(query)?.let { genres ->
+ results.add(Header(R.string.lbl_genres))
+ results.addAll(sort.genres(genres))
+ }
+ }
+
+ if (filterMode == null || filterMode == MusicMode.SONGS) {
+ library.songs.filterSongsBy(query)?.let { songs ->
+ results.add(Header(R.string.lbl_songs))
+ results.addAll(sort.songs(songs))
+ }
+ }
+
+ // Handle if we were canceled while searching.
+ return results
+ }
+
+ private fun List.filterSongsBy(value: String) =
+ searchListImpl(value) {
+ // Include both the sort name (can have normalized versions of titles) and
+ // file name (helpful for poorly tagged songs) to the filtering.
+ it.rawSortName?.contains(value, ignoreCase = true) == true ||
+ it.path.name.contains(value)
+ }
+
+ private fun List.filterAlbumsBy(value: String) =
+ // Include the sort name (can have normalized versions of names) to the filtering.
+ searchListImpl(value) { it.rawSortName?.contains(value, ignoreCase = true) == true }
+
+ private fun List.filterArtistsBy(value: String) =
+ // Include the sort name (can have normalized versions of names) to the filtering.
+ searchListImpl(value) { it.rawSortName?.contains(value, ignoreCase = true) == true }
+
+ private fun List.filterGenresBy(value: String) = searchListImpl(value) { false }
+
+ private inline fun List.searchListImpl(query: String, fallback: (T) -> Boolean) =
+ filter {
+ // See if the plain resolved name matches the query. This works for most situations.
+ val name = it.resolveName(context)
+ if (name.contains(query, ignoreCase = true)) {
+ return@filter true
+ }
+
+ // See if the sort name matches. This can sometimes be helpful as certain libraries
+ // will tag sort names to have a alphabetized version of the title.
+ val sortName = it.rawSortName
+ if (sortName != null && sortName.contains(query, ignoreCase = true)) {
+ return@filter true
+ }
+
+ // As a last-ditch effort, see if the normalized name matches. This will replace
+ // any non-alphabetical characters with their alphabetical representations, which
+ // could make it match the query.
+ val normalizedName = NORMALIZATION_SANITIZE_REGEX.replace(
+ Normalizer.normalize(name, Normalizer.Form.NFKD), "")
+ if (normalizedName.contains(query, ignoreCase = true)) {
+ return@filter true
+ }
+
+ fallback(it)
+ }
+ .ifEmpty { null }
+
+ /**
+ * Returns the ID of the filter option to currently highlight.
+ * @return A menu item ID of the filtering option selected.
+ */
+ @IdRes
+ fun getFilterOptionId() =
+ when (settings.searchFilterMode) {
+ MusicMode.SONGS -> R.id.option_filter_songs
+ MusicMode.ALBUMS -> R.id.option_filter_albums
+ MusicMode.ARTISTS -> R.id.option_filter_artists
+ MusicMode.GENRES -> R.id.option_filter_genres
+ // Null maps to filtering nothing.
+ null -> R.id.option_filter_all
+ }
+
+ /**
+ * Update the filter mode with the newly-selected filter option.
+ * @return A menu item ID of the new filtering option selected.
+ */
+ fun setFilterOptionId(@IdRes id: Int) {
+ val newFilterMode =
+ when (id) {
+ R.id.option_filter_songs -> MusicMode.SONGS
+ R.id.option_filter_albums -> MusicMode.ALBUMS
+ R.id.option_filter_artists -> MusicMode.ARTISTS
+ R.id.option_filter_genres -> MusicMode.GENRES
+ // Null maps to filtering nothing.
+ R.id.option_filter_all -> null
+ else -> error("Invalid option ID provided")
+ }
+ logD("Updating filter mode to $newFilterMode")
+ settings.searchFilterMode = newFilterMode
+ search(lastQuery)
+ }
+
+
companion object {
+ /**
+ * Converts the output of [Normalizer] to remove any junk characters added by it's
+ * replacements.
+ */
private val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
}
}