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:
parent
332d1d0170
commit
22ab5ad255
9 changed files with 208 additions and 34 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
57
app/src/main/java/org/oxycblt/auxio/songs/SongsViewModel.kt
Normal file
57
app/src/main/java/org/oxycblt/auxio/songs/SongsViewModel.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue