search: redocument
Redocument the search module.
This commit is contained in:
parent
200a3dfeaf
commit
295d2dfd39
12 changed files with 217 additions and 184 deletions
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) =
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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}+")
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue