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>
|
</activity>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
IndexerService handles querying the media database,
|
Service handling querying the media database, extracting metadata, and constructing
|
||||||
extracting metadata, and constructing the music library.
|
the music library.
|
||||||
-->
|
-->
|
||||||
<service
|
<service
|
||||||
android:name=".music.system.IndexerService"
|
android:name=".music.system.IndexerService"
|
||||||
|
@ -86,7 +86,7 @@
|
||||||
android:roundIcon="@mipmap/ic_launcher" />
|
android:roundIcon="@mipmap/ic_launcher" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
PlaybackService handles music playback, system components, and state saving.
|
Service handling music playback, system components, and state saving.
|
||||||
-->
|
-->
|
||||||
<service
|
<service
|
||||||
android:name=".playback.system.PlaybackService"
|
android:name=".playback.system.PlaybackService"
|
||||||
|
@ -107,7 +107,7 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Auxio's reciever for bluetooth headset events -->
|
<!-- Receiver for bluetooth headset events -->
|
||||||
<!-- <receiver-->
|
<!-- <receiver-->
|
||||||
<!-- android:name=".playback.system.BluetoothConnectReceiver"-->
|
<!-- android:name=".playback.system.BluetoothConnectReceiver"-->
|
||||||
<!-- android:exported="true">-->
|
<!-- android:exported="true">-->
|
||||||
|
@ -116,7 +116,7 @@
|
||||||
<!-- </intent-filter>-->
|
<!-- </intent-filter>-->
|
||||||
<!-- </receiver>-->
|
<!-- </receiver>-->
|
||||||
|
|
||||||
<!-- Auxio's one and only AppWidget. -->
|
<!-- "Now Playing" widget.. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widgets.WidgetProvider"
|
android:name=".widgets.WidgetProvider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|
|
@ -47,8 +47,6 @@ abstract class DetailAdapter(
|
||||||
// Safe to leak this since the callback will not fire during initialization
|
// Safe to leak this since the callback will not fire during initialization
|
||||||
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
|
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
|
||||||
|
|
||||||
override fun getItemCount() = differ.currentList.size
|
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (differ.currentList[position]) {
|
when (differ.currentList[position]) {
|
||||||
// Implement support for headers and sort headers
|
// 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) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
super.onBindViewHolder(holder, position)
|
||||||
when (val item = differ.currentList[position]) {
|
when (val item = differ.currentList[position]) {
|
||||||
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
|
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
|
||||||
is Artist -> (holder as ArtistViewHolder).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>
|
override val currentList: List<Item>
|
||||||
get() = differ.currentList
|
get() = differ.currentList
|
||||||
|
|
||||||
override fun getItemCount() = differ.currentList.size
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
AlbumViewHolder.new(parent)
|
AlbumViewHolder.new(parent)
|
||||||
|
|
||||||
|
|
|
@ -123,8 +123,6 @@ class ArtistListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRe
|
||||||
override val currentList: List<Item>
|
override val currentList: List<Item>
|
||||||
get() = differ.currentList
|
get() = differ.currentList
|
||||||
|
|
||||||
override fun getItemCount() = differ.currentList.size
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
ArtistViewHolder.new(parent)
|
ArtistViewHolder.new(parent)
|
||||||
|
|
||||||
|
|
|
@ -122,8 +122,6 @@ class GenreListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRec
|
||||||
override val currentList: List<Item>
|
override val currentList: List<Item>
|
||||||
get() = differ.currentList
|
get() = differ.currentList
|
||||||
|
|
||||||
override fun getItemCount() = differ.currentList.size
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
GenreViewHolder.new(parent)
|
GenreViewHolder.new(parent)
|
||||||
|
|
||||||
|
|
|
@ -162,8 +162,6 @@ class SongListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRecy
|
||||||
override val currentList: List<Item>
|
override val currentList: List<Item>
|
||||||
get() = differ.currentList
|
get() = differ.currentList
|
||||||
|
|
||||||
override fun getItemCount() = differ.currentList.size
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
SongViewHolder.new(parent)
|
SongViewHolder.new(parent)
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,14 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerV
|
||||||
private var currentItem: Item? = null
|
private var currentItem: Item? = null
|
||||||
private var isPlaying = false
|
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>) {
|
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
||||||
if (payloads.isEmpty()) {
|
if (payloads.isEmpty()) {
|
||||||
// Not updating any indicator-specific things, so delegate to the concrete
|
// 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)
|
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.
|
* Update the currently playing item in the list.
|
||||||
* @param item The item currently being played, or null if it is not being played.
|
* @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.
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*
|
*
|
||||||
* TODO: Make seek thumb grow when selected
|
* 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.Genre
|
||||||
import org.oxycblt.auxio.music.Song
|
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) :
|
class SearchAdapter(private val listener: ExtendedListListener) :
|
||||||
SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
||||||
private val differ = AsyncListDiffer(this, DIFF_CALLBACK)
|
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) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (differ.currentList[position]) {
|
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 fun isItemFullWidth(position: Int) = differ.currentList[position] is Header
|
||||||
|
|
||||||
override val currentList: List<Item>
|
/**
|
||||||
get() = differ.currentList
|
* Asynchronously update the list with new items. Assumes that the list only contains
|
||||||
|
* supported data..
|
||||||
fun submitList(list: List<Item>, callback: () -> Unit) {
|
* @param newList The new [Item]s for the adapter to display.
|
||||||
differ.submitList(list, callback)
|
* @param callback A block called when the asynchronous update is completed.
|
||||||
|
*/
|
||||||
|
fun submitList(newList: List<Item>, callback: () -> Unit) {
|
||||||
|
differ.submitList(newList, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/** A comparator that can be used with DiffUtil. */
|
||||||
private val DIFF_CALLBACK =
|
private val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleItemCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: 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.isInvisible
|
||||||
import androidx.core.view.postDelayed
|
import androidx.core.view.postDelayed
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
@ -43,21 +42,22 @@ import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [Fragment] that allows for the searching of the entire music library. TODO: Minor rework with
|
* The [ListFragment] providing search functionality for the music library.
|
||||||
* better keyboard logic, recycler updating, and chips
|
*
|
||||||
|
* TODO: Better keyboard management
|
||||||
|
*
|
||||||
|
* TODO: Multi-filtering with chips
|
||||||
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
// SearchViewModel is only scoped to this Fragment
|
|
||||||
private val searchModel: SearchViewModel by androidViewModels()
|
private val searchModel: SearchViewModel by androidViewModels()
|
||||||
|
|
||||||
private val searchAdapter = SearchAdapter(this)
|
private val searchAdapter = SearchAdapter(this)
|
||||||
|
private var launchedKeyboard = false
|
||||||
private val imm: InputMethodManager by lifecycleObject { binding ->
|
private val imm: InputMethodManager by lifecycleObject { binding ->
|
||||||
binding.context.getSystemServiceCompat(InputMethodManager::class)
|
binding.context.getSystemServiceCompat(InputMethodManager::class)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var launchedKeyboard = false
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||||
|
@ -75,19 +75,11 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
binding.searchToolbar.apply {
|
binding.searchToolbar.apply {
|
||||||
val itemIdToSelect =
|
// Initialize the current filtering mode.
|
||||||
when (searchModel.filterMode) {
|
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
|
||||||
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
|
|
||||||
|
|
||||||
setNavigationOnClickListener {
|
setNavigationOnClickListener {
|
||||||
// Drop keyboard as it's no longer needed
|
// Keyboard is no longer needed, drop it.
|
||||||
imm.hide()
|
imm.hide()
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
|
@ -112,7 +104,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
|
|
||||||
collectImmediately(searchModel.searchResults, ::updateResults)
|
collectImmediately(searchModel.searchResults, ::updateSearchResults)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||||
|
@ -134,7 +126,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
if (item.itemId != R.id.submenu_filtering) {
|
if (item.itemId != R.id.submenu_filtering) {
|
||||||
// Is a change in filter mode and not just a junk submenu click, update
|
// Is a change in filter mode and not just a junk submenu click, update
|
||||||
// the filtering within SearchViewModel.
|
// the filtering within SearchViewModel.
|
||||||
searchModel.updateFilterModeWithId(item.itemId)
|
searchModel.setFilterOptionId(item.itemId)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,7 +140,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
MusicMode.SONGS -> playbackModel.playFromAll(music)
|
MusicMode.SONGS -> playbackModel.playFromAll(music)
|
||||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
|
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
|
||||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
|
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
|
||||||
else -> error("Unexpected playback mode: ${mode}")
|
else -> error("Unexpected playback mode: $mode")
|
||||||
}
|
}
|
||||||
is MusicParent -> navModel.exploreNavigateTo(music)
|
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()
|
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()) {
|
searchAdapter.submitList(results.toMutableList()) {
|
||||||
// I would make it so that the position is only scrolled back to the top when
|
// 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
|
// the query actually changes instead of once every re-creation event, but sadly
|
||||||
// that doesn't seem possible.
|
// that doesn't seem possible.
|
||||||
binding.searchRecycler.scrollToPosition(0)
|
binding.searchRecycler.scrollToPosition(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.searchRecycler.isInvisible = results.isEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
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) {
|
private fun InputMethodManager.show(view: View) {
|
||||||
view.apply {
|
view.apply {
|
||||||
requestFocus()
|
requestFocus()
|
||||||
|
@ -211,6 +207,9 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely hide the keyboard from this view.
|
||||||
|
*/
|
||||||
private fun InputMethodManager.hide() {
|
private fun InputMethodManager.hide() {
|
||||||
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package org.oxycblt.auxio.search
|
package org.oxycblt.auxio.search
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.IdRes
|
import androidx.annotation.IdRes
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
@ -45,54 +44,67 @@ import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.logD
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SearchViewModel(application: Application) :
|
class SearchViewModel(application: Application) :
|
||||||
AndroidViewModel(application), MusicStore.Callback {
|
AndroidViewModel(application), MusicStore.Callback {
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private val settings = Settings(application)
|
private val settings = Settings(context)
|
||||||
|
|
||||||
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 var lastQuery: String? = null
|
private var lastQuery: String? = null
|
||||||
private var currentSearchJob: Job? = 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 {
|
init {
|
||||||
musicStore.addCallback(this)
|
musicStore.addCallback(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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].
|
* Use [query] to perform a search of the music library. Will push results to [searchResults].
|
||||||
*/
|
*/
|
||||||
fun search(query: String?) {
|
fun search(query: String?) {
|
||||||
lastQuery = query
|
// Cancel the previous background search.
|
||||||
|
|
||||||
currentSearchJob?.cancel()
|
currentSearchJob?.cancel()
|
||||||
|
lastQuery = query
|
||||||
|
|
||||||
val library = musicStore.library
|
val library = musicStore.library
|
||||||
if (query.isNullOrEmpty() || library == null) {
|
if (query.isNullOrEmpty() || library == null) {
|
||||||
logD("No music/query, ignoring search")
|
logD("Search query is not applicable.")
|
||||||
_searchResults.value = listOf()
|
_searchResults.value = listOf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logD("Performing search for $query")
|
logD("Searching music library for $query")
|
||||||
|
|
||||||
// Searching can be quite expensive, so get on a co-routine
|
// Searching is time-consuming, so do it in the background.
|
||||||
currentSearchJob =
|
currentSearchJob =
|
||||||
viewModelScope.launch {
|
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 sort = Sort(Sort.Mode.ByName, true)
|
||||||
|
val filterMode = settings.searchFilterMode
|
||||||
val results = mutableListOf<Item>()
|
val results = mutableListOf<Item>()
|
||||||
|
|
||||||
// Note: a filter mode of null means to not filter at all.
|
// Note: A null filter mode maps to the "All" filter option, hence the check.
|
||||||
|
|
||||||
if (filterMode == null || filterMode == MusicMode.ARTISTS) {
|
if (filterMode == null || filterMode == MusicMode.ARTISTS) {
|
||||||
library.artists.filterArtistsBy(query)?.let { artists ->
|
library.artists.filterArtistsBy(query)?.let { artists ->
|
||||||
|
@ -122,76 +134,97 @@ class SearchViewModel(application: Application) :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
yield()
|
// Handle if we were canceled while searching.
|
||||||
_searchResults.value = results
|
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 current filter mode with a menu [id]. New value will be pushed to [filterMode].
|
* Update the filter mode with the newly-selected filter option.
|
||||||
|
* @return A menu item ID of the new filtering option selected.
|
||||||
*/
|
*/
|
||||||
fun updateFilterModeWithId(@IdRes id: Int) {
|
fun setFilterOptionId(@IdRes id: Int) {
|
||||||
val newFilterMode =
|
val newFilterMode =
|
||||||
when (id) {
|
when (id) {
|
||||||
R.id.option_filter_songs -> MusicMode.SONGS
|
R.id.option_filter_songs -> MusicMode.SONGS
|
||||||
R.id.option_filter_albums -> MusicMode.ALBUMS
|
R.id.option_filter_albums -> MusicMode.ALBUMS
|
||||||
R.id.option_filter_artists -> MusicMode.ARTISTS
|
R.id.option_filter_artists -> MusicMode.ARTISTS
|
||||||
R.id.option_filter_genres -> MusicMode.GENRES
|
R.id.option_filter_genres -> MusicMode.GENRES
|
||||||
else -> null
|
// Null maps to filtering nothing.
|
||||||
|
R.id.option_filter_all -> null
|
||||||
|
else -> error("Invalid option ID provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
logD("Updating filter mode to $newFilterMode")
|
logD("Updating filter mode to $newFilterMode")
|
||||||
|
|
||||||
settings.searchFilterMode = newFilterMode
|
settings.searchFilterMode = newFilterMode
|
||||||
|
|
||||||
search(lastQuery)
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
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}+")
|
private val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue