diff --git a/AuxioTODO b/AuxioTODO index bbc2d283d..b3b6aca75 100644 --- a/AuxioTODO +++ b/AuxioTODO @@ -26,8 +26,7 @@ TODOs surrounded with !s are things I tried to do, but failed for reasons includ - Exit functionality - ? Remove gap from where I removed the overflow menu ? - ? Add icons to overflow menu items ? -- ? Show Artists, Albums, and Songs in search ? -- ? Implement filtering for above ^^^ [Will resolve gap issue] ? +- ? Implement filtering for search [Will resolve gap issue] ? - ? Move into ViewPager ? - ! Move Adapter functionality to ListAdapter [RecyclerView scrolls to middle/bottom when data is re-sorted] ! diff --git a/app/src/main/java/org/oxycblt/auxio/library/LibraryFragment.kt b/app/src/main/java/org/oxycblt/auxio/library/LibraryFragment.kt index 479e9d41a..7c2de83dc 100644 --- a/app/src/main/java/org/oxycblt/auxio/library/LibraryFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/library/LibraryFragment.kt @@ -3,6 +3,7 @@ package org.oxycblt.auxio.library import android.os.Bundle import android.util.Log import android.view.LayoutInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.SearchView @@ -16,6 +17,8 @@ import androidx.transition.TransitionManager import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentLibraryBinding +import org.oxycblt.auxio.library.recycler.LibraryAdapter +import org.oxycblt.auxio.library.recycler.SearchAdapter import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.BaseModel @@ -34,8 +37,6 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener { private val musicModel: MusicViewModel by activityViewModels() private val libraryModel: LibraryViewModel by activityViewModels() - private lateinit var libraryAdapter: LibraryAdapter - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -43,6 +44,15 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener { ): View? { val binding = FragmentLibraryBinding.inflate(inflater) + val libraryAdapter = LibraryAdapter( + libraryModel.showMode.value!!, + ClickListener { navToItem(it) } + ) + + val searchAdapter = SearchAdapter( + ClickListener { navToItem(it) } + ) + // Toolbar setup binding.libraryToolbar.overflowIcon = ContextCompat.getDrawable( requireContext(), R.drawable.ic_sort_none @@ -52,18 +62,35 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener { val item = findItem(R.id.action_search) val searchView = item.actionView as SearchView + // Set up the SearchView itself searchView.queryHint = getString(R.string.hint_search_library) searchView.setOnQueryTextListener(this@LibraryFragment) searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> - this.setGroupVisible(R.id.group_sorting, !hasFocus) - - // Make sure the search item will still be visible, and then do an animation + libraryModel.updateSearchFocusStatus(hasFocus) item.isVisible = !hasFocus - TransitionManager.beginDelayedTransition( - binding.libraryToolbar, Fade() - ) - item.collapseActionView() } + + item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + // When opened, update the adapter to the SearchAdapter + // And remove the sorting group + binding.libraryRecycler.adapter = searchAdapter + setGroupVisible(R.id.group_sorting, false) + libraryModel.resetQuery() + + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + // When closed, switch back to LibraryAdapter, make the sorting + // visible again, and reset the query so that the old results wont show + // up if the search is opened again. + binding.libraryRecycler.adapter = libraryAdapter + setGroupVisible(R.id.group_sorting, true) + + return true + } + }) } binding.libraryToolbar.setOnMenuItemClickListener { @@ -82,11 +109,6 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener { true } - libraryAdapter = LibraryAdapter( - libraryModel.showMode.value!!, - ClickListener { navToItem(it) } - ) - // RecyclerView setup binding.libraryRecycler.adapter = libraryAdapter binding.libraryRecycler.applyDivider() @@ -116,17 +138,10 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener { } } - libraryModel.searchQuery.observe(viewLifecycleOwner) { query -> - // Update the adapter with the new data - libraryAdapter.updateData( - when (libraryModel.showMode.value) { - SHOW_GENRES -> musicModel.genres.value!! - SHOW_ARTISTS -> musicModel.artists.value!! - SHOW_ALBUMS -> musicModel.albums.value!! - - else -> musicModel.artists.value!! - }.filter { it.name.contains(query, true) } - ) + libraryModel.searchResults.observe(viewLifecycleOwner) { + if (libraryModel.searchHasFocus) { + searchAdapter.submitList(it) + } } Log.d(this::class.simpleName, "Fragment created.") @@ -143,7 +158,7 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean = false override fun onQueryTextChange(query: String): Boolean { - libraryModel.updateSearchQuery(query) + libraryModel.updateSearchQuery(query, musicModel) return false } diff --git a/app/src/main/java/org/oxycblt/auxio/library/LibraryViewModel.kt b/app/src/main/java/org/oxycblt/auxio/library/LibraryViewModel.kt index f21ceeafd..27dbaace3 100644 --- a/app/src/main/java/org/oxycblt/auxio/library/LibraryViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/library/LibraryViewModel.kt @@ -4,14 +4,24 @@ import android.view.MenuItem 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.MusicViewModel import org.oxycblt.auxio.recycler.SortMode +import org.oxycblt.auxio.theme.SHOW_ALBUMS import org.oxycblt.auxio.theme.SHOW_ARTISTS +import org.oxycblt.auxio.theme.SHOW_SONGS class LibraryViewModel : ViewModel() { private var mIsNavigating = false val isNavigating: Boolean get() = mIsNavigating + private var mSearchHasFocus = false + val searchHasFocus: Boolean get() = mSearchHasFocus + // TODO: Move these to prefs when they're added private val mShowMode = MutableLiveData(SHOW_ARTISTS) val showMode: LiveData get() = mShowMode @@ -19,8 +29,8 @@ class LibraryViewModel : ViewModel() { private val mSortMode = MutableLiveData(SortMode.ALPHA_DOWN) val sortMode: LiveData get() = mSortMode - private val mSearchQuery = MutableLiveData("") - val searchQuery: LiveData get() = mSearchQuery + private val mSearchResults = MutableLiveData(listOf()) + val searchResults: LiveData> get() = mSearchResults fun updateSortMode(item: MenuItem) { val mode = when (item.itemId) { @@ -36,11 +46,53 @@ class LibraryViewModel : ViewModel() { } } - fun updateSearchQuery(query: String) { - mSearchQuery.value = query + fun updateSearchQuery(query: String, musicModel: MusicViewModel) { + if (query == "") { + resetQuery() + + return + } + + // Search MusicViewModel for all the items [Artists, Albums, Songs] that contain + // the query, and update the LiveData with those items. This is done on a seperate + // thread as it can be a very intensive operation for large music libraries. + viewModelScope.launch { + val combined = mutableListOf() + + val artists = musicModel.artists.value!!.filter { it.name.contains(query, true) } + + if (artists.isNotEmpty()) { + combined.add(Header(id = SHOW_ARTISTS.toLong())) + combined.addAll(artists) + } + + val albums = musicModel.albums.value!!.filter { it.name.contains(query, true) } + + if (albums.isNotEmpty()) { + combined.add(Header(id = SHOW_ALBUMS.toLong())) + combined.addAll(albums) + } + + val songs = musicModel.songs.value!!.filter { it.name.contains(query, true) } + + if (songs.isNotEmpty()) { + combined.add(Header(id = SHOW_SONGS.toLong())) + combined.addAll(songs) + } + + mSearchResults.value = combined + } + } + + fun resetQuery() { + mSearchResults.value = listOf() } fun updateNavigationStatus(value: Boolean) { mIsNavigating = value } + + fun updateSearchFocusStatus(value: Boolean) { + mSearchHasFocus = value + } } diff --git a/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/library/recycler/LibraryAdapter.kt similarity index 69% rename from app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/library/recycler/LibraryAdapter.kt index 588463332..8e05dc3d4 100644 --- a/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/library/recycler/LibraryAdapter.kt @@ -1,4 +1,4 @@ -package org.oxycblt.auxio.library +package org.oxycblt.auxio.library.recycler import android.view.LayoutInflater import android.view.ViewGroup @@ -10,7 +10,6 @@ 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.recycler.BaseViewHolder import org.oxycblt.auxio.recycler.ClickListener import org.oxycblt.auxio.theme.SHOW_ALBUMS import org.oxycblt.auxio.theme.SHOW_ARTISTS @@ -22,7 +21,7 @@ class LibraryAdapter( val listener: ClickListener ) : RecyclerView.Adapter() { - var data: List + private var data: List init { // Assign the data on startup depending on the type @@ -41,18 +40,22 @@ class LibraryAdapter( // Return a different View Holder depending on the show type return when (showMode) { SHOW_GENRES -> GenreViewHolder( + listener, ItemGenreBinding.inflate(LayoutInflater.from(parent.context)) ) SHOW_ARTISTS -> ArtistViewHolder( + listener, ItemArtistBinding.inflate(LayoutInflater.from(parent.context)) ) SHOW_ALBUMS -> AlbumViewHolder( + listener, ItemAlbumBinding.inflate(LayoutInflater.from(parent.context)) ) else -> ArtistViewHolder( + listener, ItemArtistBinding.inflate(LayoutInflater.from(parent.context)) ) } @@ -69,42 +72,9 @@ class LibraryAdapter( } // Update the data, as its an internal value. - // TODO: Call this from a coroutine. fun updateData(newData: List) { data = newData notifyDataSetChanged() } - - // --- VIEWHOLDERS --- - - inner class GenreViewHolder( - private val binding: ItemGenreBinding - ) : BaseViewHolder(binding, listener) { - - override fun onBind(model: BaseModel) { - binding.genre = model as Genre - binding.genreName.requestLayout() - } - } - - inner class ArtistViewHolder( - private val binding: ItemArtistBinding - ) : BaseViewHolder(binding, listener) { - - override fun onBind(model: BaseModel) { - binding.artist = model as Artist - binding.artistName.requestLayout() - } - } - - inner class AlbumViewHolder( - private val binding: ItemAlbumBinding - ) : BaseViewHolder(binding, listener) { - - override fun onBind(model: BaseModel) { - binding.album = model as Album - binding.albumName.requestLayout() - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/library/recycler/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/library/recycler/SearchAdapter.kt new file mode 100644 index 000000000..0eafa1ade --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/library/recycler/SearchAdapter.kt @@ -0,0 +1,90 @@ +package org.oxycblt.auxio.library.recycler + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.databinding.ItemAlbumBinding +import org.oxycblt.auxio.databinding.ItemArtistBinding +import org.oxycblt.auxio.databinding.ItemGenreBinding +import org.oxycblt.auxio.databinding.ItemHeaderBinding +import org.oxycblt.auxio.databinding.ItemSongBinding +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.recycler.ClickListener +import org.oxycblt.auxio.recycler.DiffCallback + +class SearchAdapter( + private val listener: ClickListener +) : ListAdapter(DiffCallback()) { + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is Genre -> ITEM_TYPE_GENRE + is Artist -> ITEM_TYPE_ARTIST + is Album -> ITEM_TYPE_ALBUM + is Song -> ITEM_TYPE_SONG + is Header -> ITEM_TYPE_HEADER + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + ITEM_TYPE_GENRE -> GenreViewHolder( + listener, + ItemGenreBinding.inflate( + LayoutInflater.from(parent.context) + ) + ) + + ITEM_TYPE_ARTIST -> ArtistViewHolder( + listener, + ItemArtistBinding.inflate( + LayoutInflater.from(parent.context) + ) + ) + + ITEM_TYPE_ALBUM -> AlbumViewHolder( + listener, + ItemAlbumBinding.inflate( + LayoutInflater.from(parent.context) + ) + ) + + ITEM_TYPE_SONG -> SongViewHolder( + listener, + ItemSongBinding.inflate( + LayoutInflater.from(parent.context) + ) + ) + + ITEM_TYPE_HEADER -> HeaderViewHolder( + ItemHeaderBinding.inflate( + LayoutInflater.from(parent.context) + ) + ) + + else -> ArtistViewHolder( + listener, + ItemArtistBinding.inflate( + LayoutInflater.from(parent.context) + ) + ) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is GenreViewHolder -> holder + is ArtistViewHolder -> holder + is AlbumViewHolder -> holder + is SongViewHolder -> holder + is HeaderViewHolder -> holder + + else -> return + }.onBind(getItem(position)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/library/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/library/recycler/ViewHolders.kt new file mode 100644 index 000000000..d0391f49f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/library/recycler/ViewHolders.kt @@ -0,0 +1,75 @@ +package org.oxycblt.auxio.library.recycler + +import org.oxycblt.auxio.databinding.ItemAlbumBinding +import org.oxycblt.auxio.databinding.ItemArtistBinding +import org.oxycblt.auxio.databinding.ItemGenreBinding +import org.oxycblt.auxio.databinding.ItemHeaderBinding +import org.oxycblt.auxio.databinding.ItemSongBinding +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.recycler.BaseViewHolder +import org.oxycblt.auxio.recycler.ClickListener + +const val ITEM_TYPE_GENRE = 10 +const val ITEM_TYPE_ARTIST = 11 +const val ITEM_TYPE_ALBUM = 12 +const val ITEM_TYPE_SONG = 13 +const val ITEM_TYPE_HEADER = 14 + +class GenreViewHolder( + listener: ClickListener, + private val binding: ItemGenreBinding +) : BaseViewHolder(binding, listener) { + + override fun onBind(model: BaseModel) { + binding.genre = model as Genre + binding.genreName.requestLayout() + } +} + +class ArtistViewHolder( + listener: ClickListener, + private val binding: ItemArtistBinding +) : BaseViewHolder(binding, listener) { + + override fun onBind(model: BaseModel) { + binding.artist = model as Artist + binding.artistName.requestLayout() + } +} + +class AlbumViewHolder( + listener: ClickListener, + private val binding: ItemAlbumBinding +) : BaseViewHolder(binding, listener) { + + override fun onBind(model: BaseModel) { + binding.album = model as Album + binding.albumName.requestLayout() + } +} + +class SongViewHolder( + listener: ClickListener, + private val binding: ItemSongBinding +) : BaseViewHolder(binding, listener) { + + override fun onBind(model: BaseModel) { + binding.song = model as Song + + binding.songName.requestLayout() + binding.songInfo.requestLayout() + } +} + +class HeaderViewHolder( + private val binding: ItemHeaderBinding +) : BaseViewHolder(binding, null) { + override fun onBind(model: BaseModel) { + binding.header = model as Header + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/Models.kt b/app/src/main/java/org/oxycblt/auxio/music/Models.kt index 631f3f7f7..c6deeb31a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Models.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Models.kt @@ -90,3 +90,9 @@ data class Genre( return num } } + +// Header [Used for search, nothing else] +data class Header( + override val id: Long = -1, + override var name: String = "" +) : BaseModel() diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt index e20063497..316f0204c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt @@ -8,6 +8,9 @@ import android.text.format.DateUtils import android.widget.TextView import androidx.databinding.BindingAdapter import org.oxycblt.auxio.R +import org.oxycblt.auxio.theme.SHOW_ALBUMS +import org.oxycblt.auxio.theme.SHOW_ARTISTS +import org.oxycblt.auxio.theme.SHOW_SONGS // List of ID3 genres + Winamp extensions, each index corresponds to their int value. // There are a lot more int-genre extensions as far as Im aware, but this works for most cases. @@ -152,3 +155,16 @@ fun TextView.bindAlbumSongs(album: Album) { R.plurals.format_song_count, album.numSongs, album.numSongs ) } + +@BindingAdapter("headerText") +fun TextView.bindHeaderText(header: Header) { + text = context.getString( + when (header.id.toInt()) { + SHOW_ARTISTS -> R.string.label_artists + SHOW_ALBUMS -> R.string.label_albums + SHOW_SONGS -> R.string.label_songs + + else -> R.string.label_artists + } + ) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 0de6a1df7..ff1a30bde 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -17,7 +17,7 @@ import org.oxycblt.auxio.music.processing.MusicLoaderResponse import org.oxycblt.auxio.music.processing.MusicSorter // ViewModel for music storage. -// FIXME: This class can be improved in multiple ways +// FIXME: This system can be improved in multiple ways // - Remove lists/parents from models so that they can be parcelable // - Move genre usage to songs [If there's a way to find songs without a genre] class MusicViewModel(private val app: Application) : ViewModel() { diff --git a/app/src/main/java/org/oxycblt/auxio/recycler/BaseViewHolder.kt b/app/src/main/java/org/oxycblt/auxio/recycler/BaseViewHolder.kt index d84d74ae5..2382b89fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/recycler/BaseViewHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/recycler/BaseViewHolder.kt @@ -7,7 +7,7 @@ import org.oxycblt.auxio.music.BaseModel // ViewHolder abstraction that automates some of the things that are common for all ViewHolders. abstract class BaseViewHolder( private val baseBinding: ViewDataBinding, - protected val listener: ClickListener + protected val listener: ClickListener? ) : RecyclerView.ViewHolder(baseBinding.root) { init { baseBinding.root.layoutParams = RecyclerView.LayoutParams( @@ -16,8 +16,10 @@ abstract class BaseViewHolder( } fun bind(model: T) { - baseBinding.root.setOnClickListener { - listener.onClick(model) + if (listener != null) { + baseBinding.root.setOnClickListener { + listener.onClick(model) + } } onBind(model) diff --git a/app/src/main/java/org/oxycblt/auxio/recycler/RecyclerUtils.kt b/app/src/main/java/org/oxycblt/auxio/recycler/RecyclerUtils.kt index 27f73b925..45c7d362b 100644 --- a/app/src/main/java/org/oxycblt/auxio/recycler/RecyclerUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/recycler/RecyclerUtils.kt @@ -3,8 +3,7 @@ package org.oxycblt.auxio.recycler import androidx.recyclerview.widget.DiffUtil import org.oxycblt.auxio.music.BaseModel -// A RecyclerView click listener that can only be called once. -// Primarily used for navigation to prevent bugs when multiple items are selected. +// RecyclerView click listener class ClickListener(val onClick: (T) -> Unit) // Base Diff callback diff --git a/app/src/main/java/org/oxycblt/auxio/theme/PrefConstants.kt b/app/src/main/java/org/oxycblt/auxio/theme/PrefConstants.kt index bb0abbe99..4681a2888 100644 --- a/app/src/main/java/org/oxycblt/auxio/theme/PrefConstants.kt +++ b/app/src/main/java/org/oxycblt/auxio/theme/PrefConstants.kt @@ -4,3 +4,4 @@ package org.oxycblt.auxio.theme const val SHOW_ARTISTS = 0 const val SHOW_ALBUMS = 1 const val SHOW_GENRES = 2 +const val SHOW_SONGS = 3 diff --git a/app/src/main/res/layout/item_header.xml b/app/src/main/res/layout/item_header.xml new file mode 100644 index 000000000..dbeb83f75 --- /dev/null +++ b/app/src/main/res/layout/item_header.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file