diff --git a/README.md b/README.md index 62473994e..e66967621 100644 --- a/README.md +++ b/README.md @@ -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. -**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 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 ccdf20f18..68b254806 100644 --- a/app/src/main/java/org/oxycblt/auxio/library/LibraryViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/library/LibraryViewModel.kt @@ -95,13 +95,6 @@ class LibraryViewModel : ViewModel(), SettingsManager.Callback { 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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index a07ed912e..16b8de562 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -549,20 +549,20 @@ class PlaybackStateManager private constructor() { ): MutableList { 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)) mIndex = 0 // If specified, make the current song the first member of the queue. if (keepSong) { - val song = queueToShuffle.removeAt(queueToShuffle.indexOf(mSong)) + val song = queueToShuffle.removeAt(queueToShuffle.indexOf(lastSong)) queueToShuffle.add(0, song) } else { // Otherwise, just start from the zeroth position in the queue. - mSong = mQueue[0] + mSong = queueToShuffle[0] } return queueToShuffle diff --git a/app/src/main/java/org/oxycblt/auxio/songs/SongSearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/songs/SongSearchAdapter.kt new file mode 100644 index 000000000..f186513f0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/songs/SongSearchAdapter.kt @@ -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(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) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt b/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt index d7475d60c..493b1e4a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt @@ -5,19 +5,23 @@ 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 import org.oxycblt.auxio.databinding.FragmentSongsBinding -import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.logD import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.ui.ActionMenu @@ -33,9 +37,10 @@ import kotlin.math.ceil * them. * @author OxygenCobalt */ -class SongsFragment : Fragment() { +class SongsFragment : Fragment(), SearchView.OnQueryTextListener { 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. private val indicatorTextSize: Float by lazy { @@ -53,27 +58,64 @@ class SongsFragment : Fragment() { val binding = FragmentSongsBinding.inflate(inflater) val musicStore = MusicStore.getInstance() - val settingsManager = SettingsManager.getInstance() - - val songAdapter = SongsAdapter( - musicStore.songs, - doOnClick = { - playbackModel.playSong(it, settingsManager.songPlaybackMode) - }, - doOnLongClick = { data, view -> - ActionMenu(requireCompatActivity(), view, data, ActionMenu.FLAG_NONE) - } - ) + val songAdapter = SongsAdapter(musicStore.songs, ::playSong, ::showSongMenu) + val searchAdapter = SongSearchAdapter(::playSong, ::showSongMenu) // --- UI SETUP --- binding.songToolbar.apply { setOnMenuItemClickListener { - if (it.itemId == R.id.action_shuffle) { - playbackModel.shuffleAll() + when (it.itemId) { + R.id.action_search -> { + TransitionManager.beginDelayedTransition(this, Fade()) + it.expandActionView() + } - true - } else false + R.id.action_shuffle -> { + 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 { 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 --- - setupFastScroller(binding) + songsModel.searchResults.observe(viewLifecycleOwner) { + if (binding.songRecycler.adapter == searchAdapter) { + searchAdapter.submitList(it) { + binding.songRecycler.scrollToPosition(0) + } + } + } logD("Fragment created.") 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. * @param binding Binding required @@ -202,4 +266,12 @@ class SongsFragment : Fragment() { 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) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/songs/SongsViewModel.kt b/app/src/main/java/org/oxycblt/auxio/songs/SongsViewModel.kt new file mode 100644 index 000000000..7b7cb75b8 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/songs/SongsViewModel.kt @@ -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()) + val searchResults: LiveData> 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().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() + } +} diff --git a/app/src/main/res/layout/fragment_songs.xml b/app/src/main/res/layout/fragment_songs.xml index 0a05b159a..aa715ce9a 100644 --- a/app/src/main/res/layout/fragment_songs.xml +++ b/app/src/main/res/layout/fragment_songs.xml @@ -12,9 +12,10 @@ - + + + app:showAsAction="always" /> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ed3d8562..d1e840884 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,6 +110,7 @@ Search Library… + Search Songs… Album Cover for %s