search: redocument

Redocument the search module.
This commit is contained in:
Alexander Capehart 2022-12-24 10:17:38 -07:00
parent 200a3dfeaf
commit 295d2dfd39
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 217 additions and 184 deletions

View file

@ -75,8 +75,8 @@
</activity>
<!--
IndexerService handles querying the media database,
extracting metadata, and constructing the music library.
Service handling querying the media database, extracting metadata, and constructing
the music library.
-->
<service
android:name=".music.system.IndexerService"
@ -86,7 +86,7 @@
android:roundIcon="@mipmap/ic_launcher" />
<!--
PlaybackService handles music playback, system components, and state saving.
Service handling music playback, system components, and state saving.
-->
<service
android:name=".playback.system.PlaybackService"
@ -107,7 +107,7 @@
</intent-filter>
</receiver>
<!-- Auxio's reciever for bluetooth headset events -->
<!-- Receiver for bluetooth headset events -->
<!-- <receiver-->
<!-- android:name=".playback.system.BluetoothConnectReceiver"-->
<!-- android:exported="true">-->
@ -116,7 +116,7 @@
<!-- </intent-filter>-->
<!-- </receiver>-->
<!-- Auxio's one and only AppWidget. -->
<!-- "Now Playing" widget.. -->
<receiver
android:name=".widgets.WidgetProvider"
android:exported="false"

View file

@ -47,8 +47,6 @@ abstract class DetailAdapter(
// Safe to leak this since the callback will not fire during initialization
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
override fun getItemCount() = differ.currentList.size
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
// Implement support for headers and sort headers

View file

@ -61,6 +61,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
when (val item = differ.currentList[position]) {
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
is Artist -> (holder as ArtistViewHolder).bind(item, listener)

View file

@ -148,8 +148,6 @@ class AlbumListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRec
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumViewHolder.new(parent)

View file

@ -123,8 +123,6 @@ class ArtistListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRe
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistViewHolder.new(parent)

View file

@ -122,8 +122,6 @@ class GenreListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRec
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreViewHolder.new(parent)

View file

@ -162,8 +162,6 @@ class SongListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRecy
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.new(parent)

View file

@ -35,6 +35,14 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : 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<Item>
override fun getItemCount() = currentList.size
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
if (payloads.isEmpty()) {
// Not updating any indicator-specific things, so delegate to the concrete
@ -47,13 +55,6 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : 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<Item>
/**
* Update the currently playing item in the list.
* @param item The item currently being played, or null if it is not being played.

View file

@ -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

View file

@ -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<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
private val differ = AsyncListDiffer(this, DIFF_CALLBACK)
override fun getItemCount() = differ.currentList.size
override val currentList: List<Item>
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<Item>
get() = differ.currentList
fun submitList(list: List<Item>, 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<Item>, callback: () -> Unit) {
differ.submitList(newList, callback)
}
companion object {
/** A comparator that can be used with DiffUtil. */
private val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =

View file

@ -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<FragmentSearchBinding>() {
// 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<FragmentSearchBinding>() {
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<FragmentSearchBinding>() {
// --- 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<FragmentSearchBinding>() {
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<FragmentSearchBinding>() {
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<FragmentSearchBinding>() {
}
}
private fun updateResults(results: List<Item>) {
private fun updateSearchResults(results: List<Item>) {
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<FragmentSearchBinding>() {
}
}
/**
* 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<FragmentSearchBinding>() {
}
}
/**
* Safely hide the keyboard from this view.
*/
private fun InputMethodManager.hide() {
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
}

View file

@ -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<Item>())
/** Current search results from the last [search] call. */
val searchResults: StateFlow<List<Item>>
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<Item>())
/** The results of the last [search] call, if any. */
val searchResults: StateFlow<List<Item>>
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<Item>()
// 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<Song>.filterSongsBy(value: String) =
baseFilterBy(value) {
it.rawSortName?.contains(value, ignoreCase = true) == true ||
it.path.name.contains(value)
}
private fun List<Album>.filterAlbumsBy(value: String) =
baseFilterBy(value) { it.rawSortName?.contains(value, ignoreCase = true) == true }
private fun List<Artist>.filterArtistsBy(value: String) =
baseFilterBy(value) { it.rawSortName?.contains(value, ignoreCase = true) == true }
private fun List<Genre>.filterGenresBy(value: String) = baseFilterBy(value) { false }
private inline fun <T : Music> List<T>.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<Item> {
val sort = Sort(Sort.Mode.ByName, true)
val filterMode = settings.searchFilterMode
val results = mutableListOf<Item>()
// 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<Song>.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<Album>.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<Artist>.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<Genre>.filterGenresBy(value: String) = searchListImpl(value) { false }
private inline fun <T : Music> List<T>.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}+")
}
}