From 295d2dfd39c5ceda68986410ac92291338111e99 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 24 Dec 2022 10:17:38 -0700 Subject: [PATCH] search: redocument Redocument the search module. --- app/src/main/AndroidManifest.xml | 10 +- .../auxio/detail/recycler/DetailAdapter.kt | 2 - .../detail/recycler/GenreDetailAdapter.kt | 1 + .../auxio/home/list/AlbumListFragment.kt | 2 - .../auxio/home/list/ArtistListFragment.kt | 2 - .../auxio/home/list/GenreListFragment.kt | 2 - .../auxio/home/list/SongListFragment.kt | 2 - .../list/recycler/PlayingIndicatorAdapter.kt | 15 +- .../auxio/playback/PlaybackPanelFragment.kt | 1 - .../org/oxycblt/auxio/search/SearchAdapter.kt | 22 +- .../oxycblt/auxio/search/SearchFragment.kt | 49 ++- .../oxycblt/auxio/search/SearchViewModel.kt | 293 ++++++++++-------- 12 files changed, 217 insertions(+), 184 deletions(-) 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}+") } }