Move song search to SongsFragment

Move the ability to search for songs to SongsFragment for better consistency. May switch to a dedicated search tab in the future but I generally like how this looks.
This commit is contained in:
OxygenCobalt 2021-01-06 15:54:33 -07:00
parent 332d1d0170
commit 22ab5ad255
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 208 additions and 34 deletions

View file

@ -18,7 +18,7 @@ Auxio is a local music player for android partially inspired by both Spotify and
Unlike other music players, Auxio is based off of [ExoPlayer](https://exoplayer.dev/), allowing for much better listening experience compared to the native [MediaPlayer](https://developer.android.com/guide/topics/media/mediaplayer) API. Auxio's codebase is also designed to be extendable, allowing for the addition of features that are not included in the main app. Unlike other music players, Auxio is based off of [ExoPlayer](https://exoplayer.dev/), allowing for much better listening experience compared to the native [MediaPlayer](https://developer.android.com/guide/topics/media/mediaplayer) API. Auxio's codebase is also designed to be extendable, allowing for the addition of features that are not included in the main app.
**Note:** Auxio is still early in development, meaning that some things may change as time passes. I primarily built Auxio for myself, but you can use it too, I guess.
## Screenshots ## Screenshots

View file

@ -95,13 +95,6 @@ class LibraryViewModel : ViewModel(), SettingsManager.Callback {
combined.addAll(albums) combined.addAll(albums)
} }
val songs = musicStore.songs.filter { it.name.contains(query, true) }
if (songs.isNotEmpty()) {
combined.add(Header(name = context.getString(R.string.label_songs)))
combined.addAll(songs)
}
mSearchResults.value = combined mSearchResults.value = combined
} }
} }

View file

@ -549,20 +549,20 @@ class PlaybackStateManager private constructor() {
): MutableList<Song> { ): MutableList<Song> {
val newSeed = Random.Default.nextLong() val newSeed = Random.Default.nextLong()
val lastSong = val lastSong = if (useLastSong) mQueue[0] else mSong
logD("Shuffling queue with seed $newSeed") logD("Shuffling queue with seed $newSeed")
queueToShuffle.shuffle(Random(newSeed)) queueToShuffle.shuffle(Random(newSeed))
mIndex = 0 mIndex = 0
// If specified, make the current song the first member of the queue. // If specified, make the current song the first member of the queue.
if (keepSong) { if (keepSong) {
val song = queueToShuffle.removeAt(queueToShuffle.indexOf(mSong)) val song = queueToShuffle.removeAt(queueToShuffle.indexOf(lastSong))
queueToShuffle.add(0, song) queueToShuffle.add(0, song)
} else { } else {
// Otherwise, just start from the zeroth position in the queue. // Otherwise, just start from the zeroth position in the queue.
mSong = mQueue[0] mSong = queueToShuffle[0]
} }
return queueToShuffle return queueToShuffle

View file

@ -0,0 +1,41 @@
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,19 +5,23 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.Fade
import androidx.transition.TransitionManager
import com.reddit.indicatorfastscroll.FastScrollItemIndicator import com.reddit.indicatorfastscroll.FastScrollItemIndicator
import com.reddit.indicatorfastscroll.FastScrollerView import com.reddit.indicatorfastscroll.FastScrollerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentSongsBinding import org.oxycblt.auxio.databinding.FragmentSongsBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.logD import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.ActionMenu import org.oxycblt.auxio.ui.ActionMenu
@ -33,9 +37,10 @@ import kotlin.math.ceil
* them. * them.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SongsFragment : Fragment() { class SongsFragment : Fragment(), SearchView.OnQueryTextListener {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel 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. // Lazy init the text size so that it doesn't have to be calculated every time.
private val indicatorTextSize: Float by lazy { private val indicatorTextSize: Float by lazy {
@ -53,27 +58,64 @@ class SongsFragment : Fragment() {
val binding = FragmentSongsBinding.inflate(inflater) val binding = FragmentSongsBinding.inflate(inflater)
val musicStore = MusicStore.getInstance() val musicStore = MusicStore.getInstance()
val settingsManager = SettingsManager.getInstance() val songAdapter = SongsAdapter(musicStore.songs, ::playSong, ::showSongMenu)
val searchAdapter = SongSearchAdapter(::playSong, ::showSongMenu)
val songAdapter = SongsAdapter(
musicStore.songs,
doOnClick = {
playbackModel.playSong(it, settingsManager.songPlaybackMode)
},
doOnLongClick = { data, view ->
ActionMenu(requireCompatActivity(), view, data, ActionMenu.FLAG_NONE)
}
)
// --- UI SETUP --- // --- UI SETUP ---
binding.songToolbar.apply { binding.songToolbar.apply {
setOnMenuItemClickListener { setOnMenuItemClickListener {
if (it.itemId == R.id.action_shuffle) { when (it.itemId) {
playbackModel.shuffleAll() R.id.action_search -> {
TransitionManager.beginDelayedTransition(this, Fade())
it.expandActionView()
}
true R.id.action_shuffle -> {
} else false playbackModel.shuffleAll()
}
}
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
}
})
} }
} }
@ -86,6 +128,12 @@ class SongsFragment : Fragment() {
layoutManager = GridLayoutManager(requireContext(), GridLayoutManager.VERTICAL).also { layoutManager = GridLayoutManager(requireContext(), GridLayoutManager.VERTICAL).also {
it.spanCount = spans it.spanCount = spans
it.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return if (binding.songRecycler.adapter == searchAdapter && position == 0)
2 else 1
}
}
} }
} }
@ -97,15 +145,31 @@ class SongsFragment : Fragment() {
} }
} }
setupFastScroller(binding)
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
setupFastScroller(binding) songsModel.searchResults.observe(viewLifecycleOwner) {
if (binding.songRecycler.adapter == searchAdapter) {
searchAdapter.submitList(it) {
binding.songRecycler.scrollToPosition(0)
}
}
}
logD("Fragment created.") logD("Fragment created.")
return binding.root return binding.root
} }
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. * Go through the fast scroller setup process.
* @param binding Binding required * @param binding Binding required
@ -202,4 +266,12 @@ class SongsFragment : Fragment() {
setupWithFastScroller(binding.songFastScroll) setupWithFastScroller(binding.songFastScroll)
} }
} }
private fun playSong(song: Song) {
playbackModel.playSong(song, settingsManager.songPlaybackMode)
}
private fun showSongMenu(song: Song, view: View) {
ActionMenu(requireCompatActivity(), view, song, ActionMenu.FLAG_NONE)
}
} }

View file

@ -0,0 +1,57 @@
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 {
it.addAll(musicStore.songs.filter { it.name.contains(query, true) }.toMutableList())
}
if (songs.isNotEmpty()) {
songs.add(
0,
Header(
name = context.getString(R.string.label_songs)
)
)
}
mSearchResults.value = songs
}
}
fun resetQuery() {
mSearchResults.value = listOf()
}
}

View file

@ -12,9 +12,10 @@
<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
android:id="@+id/song_toolbar" android:id="@+id/song_toolbar"
style="@style/Toolbar.Style" style="@style/Toolbar.Style.Search"
android:background="?android:attr/windowBackground" android:background="?android:attr/windowBackground"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
android:theme="@style/Toolbar.Style.Search"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/menu_songs" app:menu="@menu/menu_songs"

View file

@ -1,9 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" <menu xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> 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 <item
android:id="@+id/action_shuffle" android:id="@+id/action_shuffle"
android:icon="@drawable/ic_shuffle" android:icon="@drawable/ic_shuffle"
android:title="@string/label_shuffle" android:title="@string/label_shuffle"
app:showAsAction="ifRoom" /> app:showAsAction="always" />
</menu> </menu>

View file

@ -110,6 +110,7 @@
<!-- Hint Namespace | EditText Hints --> <!-- Hint Namespace | EditText Hints -->
<string name="hint_search_library">Search Library…</string> <string name="hint_search_library">Search Library…</string>
<string name="hint_search_songs">Search Songs…</string>
<!-- Description Namespace | Accessibility Strings --> <!-- Description Namespace | Accessibility Strings -->
<string name="description_album_cover">Album Cover for %s</string> <string name="description_album_cover">Album Cover for %s</string>