Create dedicated search tab

Move all searching to a tab of its own.
This commit is contained in:
OxygenCobalt 2021-01-11 20:17:22 -07:00
parent e029785181
commit 8f5e6621ad
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
27 changed files with 395 additions and 546 deletions

View file

@ -64,7 +64,7 @@ dependencies {
// General
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.activity:activity-ktx:1.2.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.3.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.3.0-beta01' // Outdated to fix "no event down from INITIALIZED" error
// Layout
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'

View file

@ -150,7 +150,7 @@ class AlbumDetailFragment : DetailFragment() {
* Scroll to the currently playing item.
*/
private fun scrollToPlayingItem() {
// Calculate where the item for the currently played song is, -1 if it isnt here
// Calculate where the item for the currently played song is
val pos = detailModel.albumSortMode.value!!.getSortedSongList(
detailModel.currentAlbum.value!!.songs
).indexOf(playbackModel.song.value)

View file

@ -1,8 +1,7 @@
package org.oxycblt.auxio.library.adapters
package org.oxycblt.auxio.library
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -13,8 +12,7 @@ import org.oxycblt.auxio.recycler.viewholders.ArtistViewHolder
import org.oxycblt.auxio.recycler.viewholders.GenreViewHolder
/**
* A near-identical adapter as [SearchAdapter] but this one isn't a [ListAdapter]
* Id love to unify these two adapters but that triggers a bug on the android backend, so...
* An adapter for displaying library items.
* @author OxygenCobalt
*/
class LibraryAdapter(

View file

@ -2,29 +2,22 @@ package org.oxycblt.auxio.library
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.view.forEach
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.transition.Fade
import androidx.transition.TransitionManager
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentLibraryBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.library.adapters.LibraryAdapter
import org.oxycblt.auxio.library.adapters.SearchAdapter
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.accent
@ -39,7 +32,7 @@ import org.oxycblt.auxio.ui.toColor
* A [Fragment] that shows a custom list of [Genre], [Artist], or [Album] data. Also allows for
* search functionality.
*/
class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
class LibraryFragment : Fragment() {
private val libraryModel: LibraryViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
@ -50,70 +43,21 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
): View {
val binding = FragmentLibraryBinding.inflate(inflater)
val libraryAdapter = LibraryAdapter(::onItemSelection, ::showActionsForItem)
val searchAdapter = SearchAdapter(::onItemSelection, ::showActionsForItem)
val libraryAdapter = LibraryAdapter(::onItemSelection) { data, view ->
ActionMenu(requireCompatActivity(), view, data, ActionMenu.FLAG_NONE)
}
val sortAction = binding.libraryToolbar.menu.findItem(R.id.submenu_sorting)
val filterAction = binding.libraryToolbar.menu.findItem(R.id.submenu_filtering)
val searchView: SearchView
// --- UI SETUP ---
binding.libraryToolbar.apply {
menu.apply {
val searchAction = findItem(R.id.action_search)
searchView = searchAction.actionView as SearchView
searchView.queryHint = getString(R.string.hint_search_library)
searchView.maxWidth = Int.MAX_VALUE
searchView.setOnQueryTextListener(this@LibraryFragment)
searchAction.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
binding.libraryRecycler.adapter = searchAdapter
searchAction.isVisible = false
sortAction.isVisible = false
filterAction.isVisible = true
libraryModel.resetQuery()
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
binding.libraryRecycler.adapter = libraryAdapter
searchAction.isVisible = true
sortAction.isVisible = true
filterAction.isVisible = false
libraryModel.resetQuery()
return true
}
})
}
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_search -> {
TransitionManager.beginDelayedTransition(
binding.libraryToolbar, Fade()
)
it.expandActionView()
}
R.id.submenu_sorting -> {}
R.id.submenu_filtering -> {}
else -> {
if (sortAction.isVisible) {
libraryModel.updateSortMode(it.itemId)
} else if (filterAction.isVisible) {
libraryModel.updateFilterMode(it.itemId)
libraryModel.doSearch(searchView.query.toString(), requireContext())
}
libraryModel.updateSortMode(it.itemId)
}
}
@ -126,17 +70,7 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
setHasFixedSize(true)
if (isLandscape(resources)) {
val spans = getLandscapeSpans(resources)
layoutManager = GridLayoutManager(requireContext(), spans).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return if (binding.libraryRecycler.adapter == searchAdapter) {
if (searchAdapter.currentList[position] is Header) spans else 1
} else 1
}
}
}
layoutManager = GridLayoutManager(requireContext(), getLandscapeSpans(resources))
}
}
@ -146,14 +80,6 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
libraryAdapter.updateData(it)
}
libraryModel.searchResults.observe(viewLifecycleOwner) {
if (binding.libraryRecycler.adapter == searchAdapter) {
searchAdapter.submitList(it) {
binding.libraryRecycler.scrollToPosition(0)
}
}
}
libraryModel.sortMode.observe(viewLifecycleOwner) { mode ->
logD("Updating sort mode to $mode")
@ -170,20 +96,6 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
}
}
libraryModel.filterMode.observe(viewLifecycleOwner) { mode ->
logD("Updating filter mode to $mode")
val modeId = mode.toMenuId()
filterAction.subMenu.forEach {
if (it.itemId == modeId) {
it.applyColor(accent.first.toColor(requireContext()))
} else {
it.applyColor(resolveAttr(requireContext(), android.R.attr.textColorPrimary))
}
}
}
detailModel.navToItem.observe(viewLifecycleOwner) {
if (it != null) {
libraryModel.updateNavigationStatus(false)
@ -207,31 +119,8 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
libraryModel.updateNavigationStatus(false)
}
override fun onDestroyView() {
requireView().rootView.clearFocus()
super.onDestroyView()
}
override fun onQueryTextSubmit(query: String): Boolean = false
override fun onQueryTextChange(query: String): Boolean {
libraryModel.doSearch(query, requireContext())
return true
}
/**
* Show the [ActionMenu] actions for an item.
* @param data The model that the actions should correspond to
* @param view The anchor view the menu should be bound to.
*/
private fun showActionsForItem(data: BaseModel, view: View) {
ActionMenu(requireCompatActivity(), view, data, ActionMenu.FLAG_NONE)
}
/**
* Navigate to an item, or play it, depending on what the given item is.
* Navigate to an item
* @param baseModel The data things should be done with
*/
private fun onItemSelection(baseModel: BaseModel) {

View file

@ -1,15 +1,11 @@
package org.oxycblt.auxio.library
import android.content.Context
import androidx.annotation.IdRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.recycler.DisplayMode
import org.oxycblt.auxio.recycler.SortMode
@ -27,12 +23,6 @@ class LibraryViewModel : ViewModel(), SettingsManager.Callback {
private val mLibraryData = MutableLiveData(listOf<BaseModel>())
val libraryData: LiveData<List<BaseModel>> get() = mLibraryData
private val mFilterMode = MutableLiveData(DisplayMode.SHOW_ALL)
val filterMode: LiveData<DisplayMode> get() = mFilterMode
private val mSearchResults = MutableLiveData(listOf<BaseModel>())
val searchResults: LiveData<List<BaseModel>> get() = mSearchResults
private var mIsNavigating = false
val isNavigating: Boolean get() = mIsNavigating
@ -47,143 +37,10 @@ class LibraryViewModel : ViewModel(), SettingsManager.Callback {
// Set up the display/sort modes
mDisplayMode = settingsManager.libraryDisplayMode
mSortMode.value = settingsManager.librarySortMode
mFilterMode.value = settingsManager.libraryFilterMode
updateLibraryData()
}
// --- SEARCH FUNCTIONS ---
/**
* Perform a search of the music library, given a query.
* Results are pushed to [searchResults].
* @param query The query for this search
* @param context The context needed to create the header text
*/
fun doSearch(query: String, context: Context) {
// Don't bother if the query is blank.
if (query == "") {
resetQuery()
return
}
viewModelScope.launch {
val combined = mutableListOf<BaseModel>()
// Searching is done in a different order depending on which items are being shown
// E.G If albums are being shown, then they will be the first items on the list.
when (mDisplayMode) {
DisplayMode.SHOW_GENRES -> {
searchForGenres(combined, query, context)
searchForArtists(combined, query, context)
searchForAlbums(combined, query, context)
}
DisplayMode.SHOW_ARTISTS -> {
searchForArtists(combined, query, context)
searchForAlbums(combined, query, context)
searchForGenres(combined, query, context)
}
DisplayMode.SHOW_ALBUMS -> {
searchForAlbums(combined, query, context)
searchForArtists(combined, query, context)
searchForGenres(combined, query, context)
}
else -> {}
}
mSearchResults.value = combined
}
}
private fun searchForGenres(
data: MutableList<BaseModel>,
query: String,
context: Context
): MutableList<BaseModel> {
if (mFilterMode.value == DisplayMode.SHOW_ALL ||
mFilterMode.value == DisplayMode.SHOW_GENRES
) {
val genres = musicStore.genres.filter { it.name.contains(query, true) }
if (genres.isNotEmpty()) {
data.add(Header(id = 0, name = context.getString(R.string.label_genres)))
data.addAll(genres)
}
}
return data
}
private fun searchForArtists(
data: MutableList<BaseModel>,
query: String,
context: Context
): MutableList<BaseModel> {
if (mFilterMode.value == DisplayMode.SHOW_ALL ||
mFilterMode.value == DisplayMode.SHOW_ARTISTS
) {
val artists = musicStore.artists.filter { it.name.contains(query, true) }
if (artists.isNotEmpty()) {
data.add(Header(id = 1, name = context.getString(R.string.label_artists)))
data.addAll(artists)
}
}
return data
}
private fun searchForAlbums(
data: MutableList<BaseModel>,
query: String,
context: Context
): MutableList<BaseModel> {
if (mFilterMode.value == DisplayMode.SHOW_ALL ||
mFilterMode.value == DisplayMode.SHOW_ALBUMS
) {
val albums = musicStore.albums.filter { it.name.contains(query, true) }
if (albums.isNotEmpty()) {
data.add(Header(id = 2, name = context.getString(R.string.label_albums)))
data.addAll(albums)
}
}
return data
}
/**
* Update the current filtering mode.
*/
fun updateFilterMode(@IdRes itemId: Int) {
val mode = when (itemId) {
R.id.option_filter_all -> DisplayMode.SHOW_ALL
R.id.option_filter_albums -> DisplayMode.SHOW_ALBUMS
R.id.option_filter_artists -> DisplayMode.SHOW_ARTISTS
R.id.option_filter_genres -> DisplayMode.SHOW_GENRES
else -> DisplayMode.SHOW_ALL
}
if (mFilterMode.value != mode) {
mFilterMode.value = mode
settingsManager.libraryFilterMode = mode
}
}
/**
* Reset the query.
*/
fun resetQuery() {
mSearchResults.value = listOf()
}
// --- LIBRARY FUNCTIONS ---
/**
* Update the current [SortMode] with a menu id.
* @param itemId The id of the menu item selected.

View file

@ -32,16 +32,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// If the music was already loaded, then don't do it again.
if (MusicStore.getInstance().loaded) {
findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
return null
}
): View {
val binding = FragmentLoadingBinding.inflate(inflater)
// Set up the permission launcher, as its disallowed outside of onCreate.
@ -118,6 +109,15 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// If the music was already loaded, then don't do it again.
if (MusicStore.getInstance().loaded) {
findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
}
}
// Check for two things:
// - If Auxio needs to show the rationale for getting the READ_EXTERNAL_STORAGE permission.
// - If Auxio straight up doesn't have the READ_EXTERNAL_STORAGE permission.

View file

@ -1,7 +1,6 @@
package org.oxycblt.auxio.recycler
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import org.oxycblt.auxio.R
/**
@ -12,20 +11,8 @@ enum class DisplayMode(@DrawableRes val iconRes: Int) {
SHOW_ALL(R.drawable.ic_sort_none),
SHOW_GENRES(R.drawable.ic_genre),
SHOW_ARTISTS(R.drawable.ic_artist),
SHOW_ALBUMS(R.drawable.ic_album);
/**
* Get a menu action for this show mode. Corresponds to filter actions.
*/
@IdRes
fun toMenuId(): Int {
return when (this) {
SHOW_ALL -> (R.id.option_filter_all)
SHOW_ALBUMS -> (R.id.option_filter_albums)
SHOW_ARTISTS -> (R.id.option_filter_artists)
SHOW_GENRES -> (R.id.option_filter_genres)
}
}
SHOW_ALBUMS(R.drawable.ic_album),
SHOW_SONGS(R.drawable.ic_song);
companion object {
/**

View file

@ -1,4 +1,4 @@
package org.oxycblt.auxio.library.adapters
package org.oxycblt.auxio.search
import android.view.View
import android.view.ViewGroup

View file

@ -0,0 +1,146 @@
package org.oxycblt.auxio.search
import android.content.res.ColorStateList
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.toColor
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentSearchBinding
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.getLandscapeSpans
import org.oxycblt.auxio.ui.isLandscape
import org.oxycblt.auxio.ui.requireCompatActivity
import org.oxycblt.auxio.ui.toColor
class SearchFragment : Fragment() {
// SearchViewModel only scoped to this Fragment
private val searchModel: SearchViewModel by viewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentSearchBinding.inflate(inflater)
// Apply the accents manually. Not going through the mess of converting my app's
// styling to Material given all the second-and-third-order effects it has.
val accent = accent.first.toColor(requireContext())
val searchAdapter = SearchAdapter(::onItemSelection) { data, view ->
ActionMenu(requireCompatActivity(), view, data, ActionMenu.FLAG_NONE)
}
// --- UI SETUP --
binding.searchTextLayout.apply {
boxStrokeColor = accent
hintTextColor = ColorStateList.valueOf(accent)
setEndIconTintList(
ColorStateList.valueOf(R.color.control_color.toColor(requireContext()))
)
}
binding.searchEditText.addTextChangedListener {
searchModel.doSearch(it?.toString() ?: "", requireContext())
}
binding.searchRecycler.apply {
adapter = searchAdapter
if (isLandscape(resources)) {
val spans = getLandscapeSpans(resources)
layoutManager = GridLayoutManager(requireContext(), spans).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int =
if (searchAdapter.currentList[position] is Header) spans else 1
}
}
}
}
// --- VIEWMODEL SETUP ---
searchModel.searchResults.observe(viewLifecycleOwner) {
searchAdapter.submitList(it) {
binding.searchRecycler.scrollToPosition(0)
}
if (it.isEmpty()) {
binding.searchAppbar.setExpanded(true)
binding.searchRecycler.visibility = View.GONE
} else {
binding.searchRecycler.visibility = View.VISIBLE
}
}
return binding.root
}
override fun onDestroyView() {
requireView().rootView.clearFocus()
super.onDestroyView()
}
override fun onResume() {
super.onResume()
searchModel.updateNavigationStatus(false)
}
/**
* Navigate to an item, or play it, depending on what the given item is.
* @param baseModel The data things should be done with
*/
private fun onItemSelection(baseModel: BaseModel) {
if (baseModel is Song) {
val settingsManager = SettingsManager.getInstance()
playbackModel.playSong(baseModel, settingsManager.songPlaybackMode)
return
}
requireView().rootView.clearFocus()
if (!searchModel.isNavigating) {
searchModel.updateNavigationStatus(true)
logD("Navigating to the detail fragment for ${baseModel.name}")
findNavController().navigate(
when (baseModel) {
is Genre -> SearchFragmentDirections.actionShowGenre(baseModel.id)
is Artist -> SearchFragmentDirections.actionShowArtist(baseModel.id)
is Album -> SearchFragmentDirections.actionShowAlbum(baseModel.id, false)
// If given model wasn't valid, then reset the navigation status
// and abort the navigation.
else -> {
searchModel.updateNavigationStatus(false)
return
}
}
)
}
}
}

View file

@ -0,0 +1,70 @@
package org.oxycblt.auxio.search
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.MusicStore
class SearchViewModel : ViewModel() {
private val mSearchResults = MutableLiveData(listOf<BaseModel>())
val searchResults: LiveData<List<BaseModel>> get() = mSearchResults
private var mIsNavigating = false
val isNavigating: Boolean get() = mIsNavigating
private val musicStore = MusicStore.getInstance()
fun doSearch(query: String, context: Context) {
if (query.isEmpty()) {
mSearchResults.value = listOf()
return
}
viewModelScope.launch {
val results = mutableListOf<BaseModel>()
musicStore.artists.filterByOrNull(query)?.let {
results.add(Header(id = -1, name = context.getString(R.string.label_artists)))
results.addAll(it)
}
musicStore.albums.filterByOrNull(query)?.let {
results.add(Header(id = -2, name = context.getString(R.string.label_albums)))
results.addAll(it)
}
musicStore.genres.filterByOrNull(query)?.let {
results.add(Header(id = -3, name = context.getString(R.string.label_genres)))
results.addAll(it)
}
musicStore.songs.filterByOrNull(query)?.let {
results.add(Header(id = -4, name = context.getString(R.string.label_songs)))
results.addAll(it)
}
mSearchResults.value = results
}
}
private fun List<BaseModel>.filterByOrNull(value: String): List<BaseModel>? {
val filtered = filter { it.name.contains(value, ignoreCase = true) }
return if (filtered.isNotEmpty()) filtered else null
}
/**
* Update the current navigation status
* @param value Whether LibraryFragment is navigating or not
*/
fun updateNavigationStatus(value: Boolean) {
mIsNavigating = value
}
}

View file

@ -144,22 +144,6 @@ class SettingsManager private constructor(context: Context) :
.apply()
}
/**
* The current [DisplayMode] of the library search filtering
*/
var libraryFilterMode: DisplayMode
get() = DisplayMode.valueOfOrFallback(
sharedPrefs.getString(
Keys.KEY_LIBRARY_FILTER_MODE,
DisplayMode.SHOW_ARTISTS.toString()
)
)
set(value) {
sharedPrefs.edit()
.putString(Keys.KEY_LIBRARY_FILTER_MODE, value.toString())
.apply()
}
// --- CALLBACKS ---
private val callbacks = mutableListOf<Callback>()

View file

@ -1,41 +0,0 @@
package org.oxycblt.auxio.songs
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.viewholders.HeaderViewHolder
import org.oxycblt.auxio.recycler.viewholders.SongViewHolder
class SongSearchAdapter(
private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (data: Song, view: View) -> Unit
) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback()) {
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is Header -> HeaderViewHolder.ITEM_TYPE
is Song -> SongViewHolder.ITEM_TYPE
else -> -1
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
SongViewHolder.ITEM_TYPE -> SongViewHolder.from(parent.context, doOnClick, doOnLongClick)
else -> error("Invalid viewholder item type $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = getItem(position)) {
is Header -> (holder as HeaderViewHolder).bind(item)
is Song -> (holder as SongViewHolder).bind(item)
}
}
}

View file

@ -5,16 +5,12 @@ import android.os.Build
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.Fade
import androidx.transition.TransitionManager
import com.reddit.indicatorfastscroll.FastScrollItemIndicator
import com.reddit.indicatorfastscroll.FastScrollerView
import org.oxycblt.auxio.R
@ -37,9 +33,8 @@ import kotlin.math.ceil
* them.
* @author OxygenCobalt
*/
class SongsFragment : Fragment(), SearchView.OnQueryTextListener {
class SongsFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val songsModel: SongsViewModel by activityViewModels()
private val settingsManager = SettingsManager.getInstance()
// Lazy init the text size so that it doesn't have to be calculated every time.
@ -59,18 +54,12 @@ class SongsFragment : Fragment(), SearchView.OnQueryTextListener {
val musicStore = MusicStore.getInstance()
val songAdapter = SongsAdapter(musicStore.songs, ::playSong, ::showSongMenu)
val searchAdapter = SongSearchAdapter(::playSong, ::showSongMenu)
// --- UI SETUP ---
binding.songToolbar.apply {
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_search -> {
TransitionManager.beginDelayedTransition(this, Fade())
it.expandActionView()
}
R.id.action_shuffle -> {
playbackModel.shuffleAll()
}
@ -78,45 +67,6 @@ class SongsFragment : Fragment(), SearchView.OnQueryTextListener {
true
}
menu.apply {
val searchAction = findItem(R.id.action_search)
val shuffleAction = findItem(R.id.action_shuffle)
val searchView = searchAction.actionView as SearchView
searchView.queryHint = getString(R.string.hint_search_songs)
searchView.maxWidth = Int.MAX_VALUE
searchView.setOnQueryTextListener(this@SongsFragment)
searchAction.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
binding.songRecycler.adapter = searchAdapter
searchAction.isVisible = false
shuffleAction.isVisible = false
binding.songFastScroll.visibility = View.INVISIBLE
binding.songFastScroll.isActivated = false
binding.songFastScrollThumb.visibility = View.INVISIBLE
songsModel.resetQuery()
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
songsModel.resetQuery()
binding.songRecycler.adapter = songAdapter
searchAction.isVisible = true
shuffleAction.isVisible = true
binding.songFastScroll.visibility = View.VISIBLE
binding.songFastScrollThumb.visibility = View.VISIBLE
return true
}
})
}
}
binding.songRecycler.apply {
@ -124,16 +74,7 @@ class SongsFragment : Fragment(), SearchView.OnQueryTextListener {
setHasFixedSize(true)
if (isLandscape(resources)) {
val spans = getLandscapeSpans(resources)
layoutManager = GridLayoutManager(requireContext(), spans).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return if (binding.songRecycler.adapter == searchAdapter && position == 0)
2 else 1
}
}
}
layoutManager = GridLayoutManager(requireContext(), getLandscapeSpans(resources))
}
post {
@ -146,16 +87,6 @@ class SongsFragment : Fragment(), SearchView.OnQueryTextListener {
setupFastScroller(binding)
// --- VIEWMODEL SETUP ---
songsModel.searchResults.observe(viewLifecycleOwner) {
if (binding.songRecycler.adapter == searchAdapter) {
searchAdapter.submitList(it) {
binding.songRecycler.scrollToPosition(0)
}
}
}
logD("Fragment created.")
return binding.root
@ -167,14 +98,6 @@ class SongsFragment : Fragment(), SearchView.OnQueryTextListener {
super.onDestroyView()
}
override fun onQueryTextChange(newText: String): Boolean {
songsModel.doSearch(newText, requireContext())
return true
}
override fun onQueryTextSubmit(query: String?): Boolean = false
/**
* Go through the fast scroller setup process.
* @param binding Binding required

View file

@ -1,56 +0,0 @@
package org.oxycblt.auxio.songs
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.MusicStore
class SongsViewModel : ViewModel() {
private val mSearchResults = MutableLiveData(listOf<BaseModel>())
val searchResults: LiveData<List<BaseModel>> get() = mSearchResults
private val musicStore = MusicStore.getInstance()
// --- SEARCH FUNCTIONS ---
/**
* Perform a search of the music library, given a query.
* Results are pushed to [searchResults].
* @param query The query for this search
* @param context The context needed to create the header text
*/
fun doSearch(query: String, context: Context) {
// Don't bother if the query is blank.
if (query == "") {
resetQuery()
return
}
viewModelScope.launch {
val songs = mutableListOf<BaseModel>().also { list ->
list.addAll(
musicStore.songs.filter {
it.name.contains(query, true)
}.toMutableList()
)
}
if (songs.isNotEmpty()) {
songs.add(0, Header(id = 0, name = context.getString(R.string.label_songs)))
}
mSearchResults.value = songs
}
}
fun resetQuery() {
mSearchResults.value = listOf()
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z"/>
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z" />
</vector>

View file

@ -46,9 +46,9 @@
android:id="@+id/about_desc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:paddingStart="@dimen/padding_small"
android:paddingEnd="@dimen/padding_small"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/app_desc"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/search_appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/background"
android:elevation="@dimen/elevation_normal">
<androidx.appcompat.widget.Toolbar
android:id="@+id/search_toolbar"
style="@style/Toolbar.Style"
android:elevation="@dimen/elevation_normal"
app:layout_scrollFlags="scroll|enterAlways"
app:menu="@menu/menu_search"
app:title="@string/label_search" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/search_text_layout"
style="@style/Theme.MaterialComponents.DayNight"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:boxBackgroundMode="filled"
app:boxStrokeWidth="0dp"
app:endIconContentDescription="@string/description_search_clear"
app:endIconDrawable="@drawable/ic_close"
app:endIconMode="clear_text"
app:errorEnabled="false">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/search_edit_text"
style="@style/Widget.AppCompat.EditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:hint="@string/hint_search_library"
android:imeOptions="actionSearch|flagNoExtractUi"
android:inputType="textFilter"
android:padding="@dimen/padding_medium"
android:textCursorDrawable="@drawable/ui_cursor" />
</com.google.android.material.textfield.TextInputLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_song" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_play"
android:title="@string/label_play" />

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_play_albums"
android:title="@string/label_play" />

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_play"
android:icon="@drawable/ic_play"

View file

@ -1,16 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:icon="@drawable/ic_search"
android:title="@string/label_search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="collapseActionView|always"
tools:ignore="AlwaysShowAction" />
<!--
This action has to be always shown since otherwise android mangles this action when I make '
it invisible and then visible again.
@ -18,8 +9,8 @@
-->
<item
android:id="@+id/submenu_sorting"
android:title="@string/label_sort"
android:icon="@drawable/ic_sort_none"
android:title="@string/label_sort"
app:showAsAction="always">
<menu>
<group android:id="@+id/group_sorting">
@ -38,32 +29,4 @@
</group>
</menu>
</item>
<item
android:id="@+id/submenu_filtering"
android:title="@string/label_filter"
android:icon="@drawable/ic_filter"
android:visible="false"
app:showAsAction="always">
<menu>
<group android:id="@+id/group_filtering">
<item
android:id="@+id/option_filter_all"
android:title="@string/label_filter_all"
app:showAsAction="never" />
<item
android:id="@+id/option_filter_albums"
android:title="@string/label_albums"
app:showAsAction="never" />
<item
android:id="@+id/option_filter_artists"
android:title="@string/label_artists"
app:showAsAction="never" />
<item
android:id="@+id/option_filter_genres"
android:title="@string/label_genres"
app:showAsAction="never" />
</group>
</menu>
</item>
</menu>

View file

@ -8,6 +8,10 @@
android:id="@+id/songs_fragment"
android:icon="@drawable/ic_song"
android:title="@string/label_songs" />
<item
android:id="@+id/search_fragment"
android:icon="@drawable/ic_search"
android:title="@string/label_search" />
<item
android:id="@+id/settings_fragment"
android:icon="@drawable/ic_settings"

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/submenu_filtering"
android:icon="@drawable/ic_filter"
android:title="@string/label_filter"
app:showAsAction="ifRoom">
<menu>
<group
android:id="@+id/group_filtering"
android:checkableBehavior="single">
<item
android:id="@+id/option_filter_all"
android:title="@string/label_filter_all"
app:showAsAction="never" />
<item
android:id="@+id/option_filter_songs"
android:title="@string/label_songs"
app:showAsAction="never" />
<item
android:id="@+id/option_filter_albums"
android:title="@string/label_albums"
app:showAsAction="never" />
<item
android:id="@+id/option_filter_artists"
android:title="@string/label_artists"
app:showAsAction="never" />
<item
android:id="@+id/option_filter_genres"
android:title="@string/label_genres"
app:showAsAction="never" />
</group>
</menu>
</item>
</menu>

View file

@ -1,15 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:icon="@drawable/ic_search"
android:title="@string/label_search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="collapseActionView|always"
tools:ignore="AlwaysShowAction" />
<item
android:id="@+id/action_shuffle"
android:icon="@drawable/ic_shuffle"

View file

@ -100,4 +100,31 @@
android:name="org.oxycblt.auxio.settings.SettingsFragment"
android:label="SettingsFragment"
tools:layout="@layout/fragment_settings" />
<fragment
android:id="@+id/search_fragment"
android:name="org.oxycblt.auxio.search.SearchFragment"
android:label="SearchFragment"
tools:layout="@layout/fragment_search">
<action
android:id="@+id/action_show_genre"
app:destination="@id/genre_detail_fragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_show_artist"
app:destination="@id/artist_detail_fragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_show_album"
app:destination="@id/album_detail_fragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
</navigation>

View file

@ -136,6 +136,7 @@
<string name="description_shuffle_off">Turn shuffle off</string>
<string name="description_change_loop">Change Repeat Mode</string>
<string name="description_auxio_icon">Auxio icon</string>
<string name="description_search_clear">Clear search query</string>
<!-- Placeholder Namespace | Placeholder values -->
<string name="placeholder_genre">Unknown Genre</string>