ui: rework base adapter class

Completely rework the base adapter class to require less boilerplate
and properly handle cases such as diffing. The major adapters have
been migrated to this system, but the other adapters have not been
changed so far.

This is only part 1 of a multi-part rework, as this is an incredibly
complex system.
This commit is contained in:
OxygenCobalt 2022-03-26 10:12:46 -06:00
parent 595a982d59
commit ee1a234e76
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
37 changed files with 1579 additions and 1303 deletions

View file

@ -9,6 +9,7 @@
- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese ]
- Switched to spotless and ktfmt instead of ktlint
- Migrated constants to centralized table
- Removed databinding [Greatly reduces compile times]
- A bunch of internal view implementation improvements
## v2.2.2

View file

@ -28,7 +28,7 @@ import org.oxycblt.auxio.coil.GenreImageFetcher
import org.oxycblt.auxio.coil.MusicKeyer
import org.oxycblt.auxio.settings.SettingsManager
/** TODO: Rework RecyclerView management and item dragging */
/** TODO: Rework null-safety/usage of requireNotNull */
@Suppress("UNUSED")
class AuxioApp : Application(), ImageLoaderFactory {
override fun onCreate() {

View file

@ -29,8 +29,8 @@ object IntegerTable {
const val ITEM_TYPE_GENRE = 0xA003
/** HeaderViewHolder */
const val ITEM_TYPE_HEADER = 0xA004
/** ActionHeaderViewHolder */
const val ITEM_TYPE_ACTION_HEADER = 0xA005
/** SortHeaderViewHolder */
const val ITEM_TYPE_SORT_HEADER = 0xA005
/** AlbumDetailViewHolder */
const val ITEM_TYPE_ALBUM_DETAIL = 0xA006

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle
import android.view.View
import androidx.core.view.children
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@ -26,15 +27,17 @@ import androidx.recyclerview.widget.LinearSmoothScroller
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.detail.recycler.AlbumDetailItemListener
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -44,28 +47,22 @@ import org.oxycblt.auxio.util.showToast
* The [DetailFragment] for an album.
* @author OxygenCobalt
*/
class AlbumDetailFragment : DetailFragment() {
class AlbumDetailFragment : DetailFragment(), AlbumDetailItemListener {
private val args: AlbumDetailFragmentArgs by navArgs()
private val detailAdapter = AlbumDetailAdapter(this)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setAlbumId(args.albumId)
val detailAdapter =
AlbumDetailAdapter(
playbackModel,
detailModel,
doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) },
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ALBUM) })
setupToolbar(detailModel.curAlbum.value!!, R.menu.menu_album_detail) { itemId ->
setupToolbar(detailModel.currentAlbum.value!!, R.menu.menu_album_detail) { itemId ->
when (itemId) {
R.id.action_play_next -> {
playbackModel.playNext(detailModel.curAlbum.value!!)
playbackModel.playNext(detailModel.currentAlbum.value!!)
requireContext().showToast(R.string.lbl_queue_added)
true
}
R.id.action_queue_add -> {
playbackModel.addToQueue(detailModel.curAlbum.value!!)
playbackModel.addToQueue(detailModel.currentAlbum.value!!)
requireContext().showToast(R.string.lbl_queue_added)
true
}
@ -73,20 +70,17 @@ class AlbumDetailFragment : DetailFragment() {
}
}
setupRecycler(detailAdapter) { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Album
requireBinding().detailRecycler.apply {
adapter = detailAdapter
applySpans { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is SortHeader || item is Album
}
}
// -- VIEWMODEL SETUP ---
detailModel.albumData.observe(viewLifecycleOwner, detailAdapter::submitList)
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) {
showMenu(config) { id -> id == R.id.option_sort_asc }
}
}
detailModel.albumData.observe(viewLifecycleOwner) { list -> detailAdapter.submitList(list) }
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
handleNavigation(item, detailAdapter)
@ -96,13 +90,46 @@ class AlbumDetailFragment : DetailFragment() {
playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) }
}
override fun onItemClick(item: Item) {
if (item is Song) {
playbackModel.playSong(item, PlaybackMode.IN_ALBUM)
}
}
override fun onOpenMenu(item: Item, anchor: View) {
newMenu(anchor, item)
}
override fun onPlayParent() {
playbackModel.playAlbum(requireNotNull(detailModel.currentAlbum.value), false)
}
override fun onShuffleParent() {
playbackModel.playAlbum(requireNotNull(detailModel.currentAlbum.value), true)
}
override fun onShowSortMenu(anchor: View) {
showSortMenu(
anchor,
detailModel.albumSort,
onConfirm = { detailModel.albumSort = it },
showItem = { it == R.id.option_sort_asc })
}
override fun onNavigateToArtist() {
findNavController()
.navigate(
AlbumDetailFragmentDirections.actionShowArtist(
requireNotNull(detailModel.currentAlbum.value).artist.id))
}
private fun handleNavigation(item: Music?, adapter: AlbumDetailAdapter) {
val binding = requireBinding()
when (item) {
// Songs should be scrolled to if the album matches, or a new detail
// fragment should be launched otherwise.
is Song -> {
if (detailModel.curAlbum.value!!.id == item.album.id) {
if (detailModel.currentAlbum.value!!.id == item.album.id) {
logD("Navigating to a song in this album")
scrollToItem(item.id, adapter)
detailModel.finishNavToItem()
@ -116,7 +143,7 @@ class AlbumDetailFragment : DetailFragment() {
// If the album matches, no need to do anything. Otherwise launch a new
// detail fragment.
is Album -> {
if (detailModel.curAlbum.value!!.id == item.id) {
if (detailModel.currentAlbum.value!!.id == item.id) {
logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0)
detailModel.finishNavToItem()
@ -169,7 +196,7 @@ class AlbumDetailFragment : DetailFragment() {
}
if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM &&
playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id) {
playbackModel.parent.value?.id == detailModel.currentAlbum.value!!.id) {
adapter.highlightSong(song, binding.detailRecycler)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS

View file

@ -18,21 +18,24 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.detail.recycler.DetailItemListener
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -40,40 +43,27 @@ import org.oxycblt.auxio.util.logW
* The [DetailFragment] for an artist.
* @author OxygenCobalt
*/
class ArtistDetailFragment : DetailFragment() {
class ArtistDetailFragment : DetailFragment(), DetailItemListener {
private val args: ArtistDetailFragmentArgs by navArgs()
private val detailAdapter = ArtistDetailAdapter(this)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setArtistId(args.artistId)
val detailAdapter =
ArtistDetailAdapter(
playbackModel,
doOnClick = { data ->
if (!detailModel.isNavigating) {
detailModel.setNavigating(true)
findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(data.id))
}
},
doOnSongClick = { data -> playbackModel.playSong(data, PlaybackMode.IN_ARTIST) },
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ARTIST) })
setupToolbar(detailModel.currentArtist.value!!)
setupRecycler(detailAdapter) { pos ->
// If the item is an ActionHeader we need to also make the item full-width
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Artist
requireBinding().detailRecycler.apply {
adapter = detailAdapter
applySpans { pos ->
// If the item is an ActionHeader we need to also make the item full-width
val item = detailAdapter.currentList[pos]
item is Header || item is SortHeader || item is Artist
}
}
// --- VIEWMODEL SETUP ---
detailModel.artistData.observe(viewLifecycleOwner, detailAdapter::submitList)
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) {
showMenu(config) { id -> id != R.id.option_sort_artist }
}
detailModel.artistData.observe(viewLifecycleOwner) { list ->
detailAdapter.submitList(list)
}
detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation)
@ -87,6 +77,35 @@ class ArtistDetailFragment : DetailFragment() {
}
}
override fun onItemClick(item: Item) {
when (item) {
is Song -> playbackModel.playSong(item, PlaybackMode.IN_ARTIST)
is Album ->
findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
}
}
override fun onOpenMenu(item: Item, anchor: View) {
newMenu(anchor, item)
}
override fun onPlayParent() {
playbackModel.playArtist(requireNotNull(detailModel.currentArtist.value), false)
}
override fun onShuffleParent() {
playbackModel.playArtist(requireNotNull(detailModel.currentArtist.value), true)
}
override fun onShowSortMenu(anchor: View) {
showSortMenu(
anchor,
detailModel.artistSort,
onConfirm = { detailModel.artistSort = it },
showItem = { id -> id != R.id.option_sort_artist })
}
private fun handleNavigation(item: Music?) {
val binding = requireBinding()

View file

@ -18,19 +18,19 @@
package org.oxycblt.auxio.detail
import android.view.LayoutInflater
import android.view.View
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD
/**
@ -49,10 +49,9 @@ abstract class DetailFragment : ViewBindingFragment<FragmentDetailBinding>() {
detailModel.setNavigating(false)
}
override fun onStop() {
super.onStop()
// Cancel all pending menus when this fragment stops to prevent bugs/crashes
detailModel.finishShowMenu(null)
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailRecycler.adapter = null
}
/**
@ -81,55 +80,44 @@ abstract class DetailFragment : ViewBindingFragment<FragmentDetailBinding>() {
}
}
/** Shortcut method for recyclerview setup */
protected fun setupRecycler(
detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>,
gridLookup: (Int) -> Boolean
) {
requireBinding().detailRecycler.apply {
adapter = detailAdapter
setHasFixedSize(true)
applySpans(gridLookup)
}
}
/**
* Shortcut method for spinning up the sorting [PopupMenu]
* @param config The initial configuration to apply to the menu. This is provided by
* [DetailViewModel.showMenu].
* @param anchor The view to anchor the sort menu to
* @param sort The initial sort
* @param onConfirm What to do when the sort is confirmed
* @param showItem Which menu items to keep
*/
protected fun showMenu(
config: DetailViewModel.MenuConfig,
showItem: ((Int) -> Boolean)? = null
protected fun showSortMenu(
anchor: View,
sort: Sort,
onConfirm: (Sort) -> Unit,
showItem: ((Int) -> Boolean)? = null,
) {
logD("Launching menu [$config]")
logD("Launching menu")
PopupMenu(config.anchor.context, config.anchor).apply {
PopupMenu(anchor.context, anchor).apply {
inflate(R.menu.menu_detail_sort)
setOnMenuItemClickListener { item ->
if (item.itemId == R.id.option_sort_asc) {
item.isChecked = !item.isChecked
detailModel.finishShowMenu(config.sortMode.ascending(item.isChecked))
onConfirm(sort.ascending(item.isChecked))
} else {
item.isChecked = true
detailModel.finishShowMenu(config.sortMode.assignId(item.itemId))
onConfirm(requireNotNull(sort.assignId(item.itemId)))
}
true
}
setOnDismissListener { detailModel.finishShowMenu(null) }
if (showItem != null) {
for (item in menu.children) {
item.isVisible = showItem(item.itemId)
}
}
menu.findItem(config.sortMode.itemId).isChecked = true
menu.findItem(R.id.option_sort_asc).isChecked = config.sortMode.isAscending
menu.findItem(sort.itemId).isChecked = true
menu.findItem(R.id.option_sort_asc).isChecked = sort.isAscending
show()
}

View file

@ -17,21 +17,19 @@
package org.oxycblt.auxio.detail
import android.view.View
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
@ -44,14 +42,23 @@ import org.oxycblt.auxio.util.logD
* @author OxygenCobalt
*/
class DetailViewModel : ViewModel() {
private val settingsManager = SettingsManager.getInstance()
private val mCurrentAlbum = MutableLiveData<Album?>()
val curAlbum: LiveData<Album?>
val currentAlbum: LiveData<Album?>
get() = mCurrentAlbum
private val mAlbumData = MutableLiveData(listOf<Item>())
val albumData: LiveData<List<Item>>
get() = mAlbumData
var albumSort: Sort
get() = settingsManager.detailAlbumSort
set(value) {
settingsManager.detailAlbumSort = value
refreshAlbumData()
}
private val mCurrentArtist = MutableLiveData<Artist?>()
val currentArtist: LiveData<Artist?>
get() = mCurrentArtist
@ -59,6 +66,13 @@ class DetailViewModel : ViewModel() {
private val mArtistData = MutableLiveData(listOf<Item>())
val artistData: LiveData<List<Item>> = mArtistData
var artistSort: Sort
get() = settingsManager.detailArtistSort
set(value) {
settingsManager.detailArtistSort = value
refreshArtistData()
}
private val mCurrentGenre = MutableLiveData<Genre?>()
val currentGenre: LiveData<Genre?>
get() = mCurrentGenre
@ -66,10 +80,12 @@ class DetailViewModel : ViewModel() {
private val mGenreData = MutableLiveData(listOf<Item>())
val genreData: LiveData<List<Item>> = mGenreData
data class MenuConfig(val anchor: View, val sortMode: Sort)
private val mShowMenu = MutableLiveData<MenuConfig?>(null)
val showMenu: LiveData<MenuConfig?> = mShowMenu
var genreSort: Sort
get() = settingsManager.detailGenreSort
set(value) {
settingsManager.detailGenreSort = value
refreshGenreData()
}
private val mNavToItem = MutableLiveData<Music?>()
@ -80,9 +96,6 @@ class DetailViewModel : ViewModel() {
var isNavigating = false
private set
private var currentMenuContext: DisplayMode? = null
private val settingsManager = SettingsManager.getInstance()
fun setAlbumId(id: Long) {
if (mCurrentAlbum.value?.id == id) return
val musicStore = MusicStore.requireInstance()
@ -104,32 +117,6 @@ class DetailViewModel : ViewModel() {
refreshGenreData()
}
/** Mark that the menu process is done with the new [Sort]. Pass null if there was no change. */
fun finishShowMenu(newMode: Sort?) {
mShowMenu.value = null
if (newMode != null) {
logD("Applying new sort mode")
when (currentMenuContext) {
DisplayMode.SHOW_ALBUMS -> {
settingsManager.detailAlbumSort = newMode
refreshAlbumData()
}
DisplayMode.SHOW_ARTISTS -> {
settingsManager.detailArtistSort = newMode
refreshArtistData()
}
DisplayMode.SHOW_GENRES -> {
settingsManager.detailGenreSort = newMode
refreshGenreData()
}
else -> {}
}
}
currentMenuContext = null
}
/** Navigate to an item, whether a song/album/artist */
fun navToItem(item: Music) {
mNavToItem.value = item
@ -150,17 +137,7 @@ class DetailViewModel : ViewModel() {
val genre = requireNotNull(currentGenre.value)
val data = mutableListOf<Item>(genre)
data.add(
ActionHeader(
id = -2,
string = R.string.lbl_songs,
icon = R.drawable.ic_sort,
desc = R.string.lbl_sort,
onClick = { view ->
currentMenuContext = DisplayMode.SHOW_GENRES
mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort)
}))
data.add(SortHeader(-2, R.string.lbl_songs))
data.addAll(settingsManager.detailGenreSort.genre(currentGenre.value!!))
mGenreData.value = data
@ -171,21 +148,9 @@ class DetailViewModel : ViewModel() {
val artist = requireNotNull(currentArtist.value)
val data = mutableListOf<Item>(artist)
data.add(Header(id = -2, string = R.string.lbl_albums))
data.add(Header(-2, R.string.lbl_albums))
data.addAll(Sort.ByYear(false).albums(artist.albums))
data.add(
ActionHeader(
id = -3,
string = R.string.lbl_songs,
icon = R.drawable.ic_sort,
desc = R.string.lbl_sort,
onClick = { view ->
currentMenuContext = DisplayMode.SHOW_ARTISTS
mShowMenu.value = MenuConfig(view, settingsManager.detailArtistSort)
}))
data.add(SortHeader(-3, R.string.lbl_songs))
data.addAll(settingsManager.detailArtistSort.artist(artist))
mArtistData.value = data.toList()
@ -193,21 +158,11 @@ class DetailViewModel : ViewModel() {
private fun refreshAlbumData() {
logD("Refreshing album data")
val album = requireNotNull(curAlbum.value)
val album = requireNotNull(currentAlbum.value)
val data = mutableListOf<Item>(album)
data.add(
ActionHeader(
id = -2,
string = R.string.lbl_songs,
icon = R.drawable.ic_sort,
desc = R.string.lbl_sort,
onClick = { view ->
currentMenuContext = DisplayMode.SHOW_ALBUMS
mShowMenu.value = MenuConfig(view, settingsManager.detailAlbumSort)
}))
data.addAll(settingsManager.detailAlbumSort.album(curAlbum.value!!))
data.add(SortHeader(id = -2, R.string.lbl_albums))
data.addAll(settingsManager.detailAlbumSort.album(currentAlbum.value!!))
mAlbumData.value = data
}

View file

@ -18,20 +18,23 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailItemListener
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -39,39 +42,56 @@ import org.oxycblt.auxio.util.logW
* The [DetailFragment] for a genre.
* @author OxygenCobalt
*/
class GenreDetailFragment : DetailFragment() {
class GenreDetailFragment : DetailFragment(), DetailItemListener {
private val args: GenreDetailFragmentArgs by navArgs()
private val detailAdapter = GenreDetailAdapter(this)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setGenreId(args.genreId)
val detailAdapter =
GenreDetailAdapter(
playbackModel,
doOnClick = { song -> playbackModel.playSong(song, PlaybackMode.IN_GENRE) },
doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_GENRE) })
setupToolbar(detailModel.currentGenre.value!!)
setupRecycler(detailAdapter) { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Genre
binding.detailRecycler.apply {
adapter = detailAdapter
applySpans { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is SortHeader || item is Genre
}
}
// --- VIEWMODEL SETUP ---
detailModel.genreData.observe(viewLifecycleOwner, detailAdapter::submitList)
detailModel.genreData.observe(viewLifecycleOwner) { list -> detailAdapter.submitList(list) }
detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation)
playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) }
}
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) {
showMenu(config)
}
override fun onItemClick(item: Item) {
when (item) {
is Song -> playbackModel.playSong(item, PlaybackMode.IN_GENRE)
is Album ->
findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
}
}
override fun onOpenMenu(item: Item, anchor: View) {
newMenu(anchor, item)
}
override fun onPlayParent() {
playbackModel.playGenre(requireNotNull(detailModel.currentGenre.value), false)
}
override fun onShuffleParent() {
playbackModel.playGenre(requireNotNull(detailModel.currentGenre.value), true)
}
override fun onShowSortMenu(anchor: View) {
showSortMenu(anchor, detailModel.genreSort, onConfirm = { detailModel.genreSort = it })
}
private fun handleNavigation(item: Music?) {
when (item) {
// All items will launch new detail fragments.

View file

@ -17,82 +17,71 @@
package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
import android.content.Context
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toDuration
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.ItemDiffCallback
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe
/**
* An adapter for displaying the details and [Song]s of an [Album]
* An adapter for displaying [Album] information and it's children.
* @author OxygenCobalt
*/
class AlbumDetailAdapter(
private val playbackModel: PlaybackViewModel,
private val detailModel: DetailViewModel,
private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: Song) -> Unit
) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback()) {
private var currentSong: Song? = null
private var currentHolder: Highlightable? = null
class AlbumDetailAdapter(listener: AlbumDetailItemListener) :
DetailAdapter<AlbumDetailItemListener>(listener, DIFFER) {
private var highlightedSong: Song? = null
private var highlightedViewHolder: Highlightable? = null
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is Album -> IntegerTable.ITEM_TYPE_ALBUM_DETAIL
is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER
is Song -> IntegerTable.ITEM_TYPE_ALBUM_SONG
else -> -1
}
}
override fun getCreatorFromItem(item: Item) =
super.getCreatorFromItem(item)
?: when (item) {
is Album -> AlbumDetailViewHolder.CREATOR
is Song -> AlbumSongViewHolder.CREATOR
else -> null
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
IntegerTable.ITEM_TYPE_ALBUM_DETAIL ->
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context)
IntegerTable.ITEM_TYPE_ALBUM_SONG ->
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
else -> error("Invalid ViewHolder item type $viewType")
}
}
override fun getCreatorFromViewType(viewType: Int) =
super.getCreatorFromViewType(viewType)
?: when (viewType) {
AlbumDetailViewHolder.CREATOR.viewType -> AlbumDetailViewHolder.CREATOR
AlbumSongViewHolder.CREATOR.viewType -> AlbumSongViewHolder.CREATOR
else -> null
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = getItem(position)
override fun onBind(
viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: AlbumDetailItemListener
) {
super.onBind(viewHolder, item, listener)
when (item) {
is Album -> (holder as AlbumDetailViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
else -> {}
is Album -> (viewHolder as AlbumDetailViewHolder).bind(item, listener)
is Song -> (viewHolder as AlbumSongViewHolder).bind(item, listener)
}
}
if (holder is Highlightable) {
if (item.id == currentSong?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted
currentHolder?.setHighlighted(false)
currentHolder = holder
holder.setHighlighted(true)
} else {
holder.setHighlighted(false)
}
override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) {
if (item is Song && item.id == highlightedSong?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted
highlightedViewHolder?.setHighlighted(false)
highlightedViewHolder = viewHolder
viewHolder.setHighlighted(true)
} else {
viewHolder.setHighlighted(false)
}
}
@ -101,89 +90,134 @@ class AlbumDetailAdapter(
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightSong(song: Song?, recycler: RecyclerView) {
if (song == currentSong) return // Already highlighting this ViewHolder
// Clear the current ViewHolder since it's invalid
currentHolder?.setHighlighted(false)
currentHolder = null
currentSong = song
if (song != null) {
// Use existing data instead of having to re-sort it.
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
// Check if the ViewHolder for this song is visible, if it is then highlight it.
// If the ViewHolder is not visible, then the adapter should take care of it if
// it does become visible.
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let {
currentHolder = it as Highlightable
currentHolder?.setHighlighted(true)
}
}
}
if (song == highlightedSong) return
highlightedSong = song
highlightedViewHolder?.setHighlighted(false)
highlightedViewHolder = highlightItem(song, recycler)
}
inner class AlbumDetailViewHolder(private val binding: ItemDetailBinding) :
BaseViewHolder<Album>(binding) {
override fun onBind(data: Album) {
binding.detailCover.apply {
bindAlbumCover(data)
contentDescription = context.getString(R.string.desc_album_cover, data.resolvedName)
}
binding.detailName.textSafe = data.resolvedName
binding.detailSubhead.apply {
textSafe = data.artist.resolvedName
setOnClickListener { detailModel.navToItem(data.artist) }
}
binding.detailInfo.apply {
text =
context.getString(
R.string.fmt_three,
data.year?.toString() ?: context.getString(R.string.def_date),
context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size),
data.totalDuration)
}
binding.detailPlayButton.setOnClickListener { playbackModel.playAlbum(data, false) }
binding.detailShuffleButton.setOnClickListener { playbackModel.playAlbum(data, true) }
}
}
inner class AlbumSongViewHolder(
private val binding: ItemAlbumSongBinding,
) : BaseViewHolder<Song>(binding, doOnClick, doOnLongClick), Highlightable {
override fun onBind(data: Song) {
// Hide the track number view if the song does not have a track.
if (data.track != null) {
binding.songTrack.apply {
textSafe = context.getString(R.string.fmt_number, data.track)
isInvisible = false
companion object {
private val DIFFER =
object : ItemDiffCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Album && newItem is Album ->
AlbumDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader ->
SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem)
}
}
binding.songTrackPlaceholder.isInvisible = true
} else {
binding.songTrack.apply {
textSafe = ""
isInvisible = true
}
binding.songTrackPlaceholder.isInvisible = false
}
binding.songName.textSafe = data.resolvedName
binding.songDuration.textSafe = data.seconds.toDuration(false)
}
override fun setHighlighted(isHighlighted: Boolean) {
binding.songName.isActivated = isHighlighted
binding.songTrack.isActivated = isHighlighted
binding.songTrackPlaceholder.isActivated = isHighlighted
}
}
}
interface AlbumDetailItemListener : DetailItemListener {
fun onNavigateToArtist()
}
private class AlbumDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
BindingViewHolder<Album, AlbumDetailItemListener>(binding.root) {
override fun bind(item: Album, listener: AlbumDetailItemListener) {
binding.detailCover.bindAlbumCover(item)
binding.detailName.textSafe = item.resolvedName
binding.detailSubhead.apply {
textSafe = item.resolvedArtistName
setOnClickListener { listener.onNavigateToArtist() }
}
binding.detailInfo.apply {
text =
context.getString(
R.string.fmt_three,
item.year?.toString() ?: context.getString(R.string.def_date),
context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size),
item.totalDuration)
}
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
companion object {
val CREATOR =
object : Creator<AlbumDetailViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_ALBUM_DETAIL
override fun create(context: Context) =
AlbumDetailViewHolder(ItemDetailBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Album>() {
override fun areItemsTheSame(oldItem: Album, newItem: Album) =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.resolvedArtistName == newItem.resolvedArtistName &&
oldItem.year == newItem.year &&
oldItem.songs.size == newItem.songs.size &&
oldItem.totalDuration == newItem.totalDuration
}
}
}
private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) :
BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable {
override fun bind(item: Song, listener: MenuItemListener) {
// Hide the track number view if the song does not have a track.
if (item.track != null) {
binding.songTrack.apply {
textSafe = context.getString(R.string.fmt_number, item.track)
isInvisible = false
}
binding.songTrackPlaceholder.isInvisible = true
} else {
binding.songTrack.apply {
textSafe = ""
isInvisible = true
}
binding.songTrackPlaceholder.isInvisible = false
}
binding.songName.textSafe = item.resolvedName
binding.songDuration.textSafe = item.seconds.toDuration(false)
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
}
override fun setHighlighted(isHighlighted: Boolean) {
binding.songName.isActivated = isHighlighted
binding.songTrack.isActivated = isHighlighted
binding.songTrackPlaceholder.isActivated = isHighlighted
}
companion object {
val CREATOR =
object : Creator<AlbumSongViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_ALBUM_SONG
override fun create(context: Context) =
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.duration == newItem.duration
}
}
}

View file

@ -17,9 +17,7 @@
package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
@ -28,88 +26,76 @@ import org.oxycblt.auxio.coil.bindArtistImage
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder
import org.oxycblt.auxio.ui.ArtistViewHolder
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.ItemDiffCallback
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe
/**
* An adapter for displaying the [Album]s and [Song]s of an artist.
* An adapter for displaying [Artist] information and it's children. Unlike the other adapters, this
* one actually contains both album information and song information.
* @author OxygenCobalt
*/
class ArtistDetailAdapter(
private val playbackModel: PlaybackViewModel,
private val doOnClick: (data: Album) -> Unit,
private val doOnSongClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: Item) -> Unit,
) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback()) {
class ArtistDetailAdapter(listener: DetailItemListener) :
DetailAdapter<DetailItemListener>(listener, DIFFER) {
private var currentAlbum: Album? = null
private var currentAlbumHolder: Highlightable? = null
private var currentSong: Song? = null
private var currentSongHolder: Highlightable? = null
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is Artist -> IntegerTable.ITEM_TYPE_ARTIST_DETAIL
is Album -> IntegerTable.ITEM_TYPE_ARTIST_ALBUM
is Song -> IntegerTable.ITEM_TYPE_ARTIST_SONG
is Header -> IntegerTable.ITEM_TYPE_HEADER
is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER
else -> -1
}
}
override fun getCreatorFromItem(item: Item) =
super.getCreatorFromItem(item)
?: when (item) {
is Artist -> ArtistDetailViewHolder.CREATOR
is Album -> ArtistAlbumViewHolder.CREATOR
is Song -> ArtistSongViewHolder.CREATOR
else -> null
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
IntegerTable.ITEM_TYPE_ARTIST_DETAIL ->
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
IntegerTable.ITEM_TYPE_ARTIST_ALBUM ->
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
IntegerTable.ITEM_TYPE_ARTIST_SONG ->
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
IntegerTable.ITEM_TYPE_HEADER -> HeaderViewHolder.from(parent.context)
IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = getItem(position)
override fun getCreatorFromViewType(viewType: Int) =
super.getCreatorFromViewType(viewType)
?: when (viewType) {
ArtistDetailViewHolder.CREATOR.viewType -> ArtistDetailViewHolder.CREATOR
ArtistAlbumViewHolder.CREATOR.viewType -> ArtistAlbumViewHolder.CREATOR
ArtistSongViewHolder.CREATOR.viewType -> ArtistSongViewHolder.CREATOR
else -> null
}
override fun onBind(
viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: DetailItemListener
) {
super.onBind(viewHolder, item, listener)
when (item) {
is Artist -> (holder as ArtistDetailViewHolder).bind(item)
is Album -> (holder as ArtistAlbumViewHolder).bind(item)
is Song -> (holder as ArtistSongViewHolder).bind(item)
is Header -> (holder as HeaderViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
is Artist -> (viewHolder as ArtistDetailViewHolder).bind(item, listener)
is Album -> (viewHolder as ArtistAlbumViewHolder).bind(item, listener)
is Song -> (viewHolder as ArtistSongViewHolder).bind(item, listener)
else -> {}
}
}
if (holder is Highlightable) {
// If the item corresponds to a currently playing song/album then highlight it
if (item.id == currentAlbum?.id && item is Album) {
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = holder
holder.setHighlighted(true)
} else if (item.id == currentSong?.id && item is Song) {
currentSongHolder?.setHighlighted(false)
currentSongHolder = holder
holder.setHighlighted(true)
} else {
holder.setHighlighted(false)
}
override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) {
// If the item corresponds to a currently playing song/album then highlight it
if (item.id == currentAlbum?.id && item is Album) {
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = viewHolder
viewHolder.setHighlighted(true)
} else if (item.id == currentSong?.id && item is Song) {
currentSongHolder?.setHighlighted(false)
currentSongHolder = viewHolder
viewHolder.setHighlighted(true)
} else {
viewHolder.setHighlighted(false)
}
}
@ -118,26 +104,10 @@ class ArtistDetailAdapter(
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightAlbum(album: Album?, recycler: RecyclerView) {
if (album == currentAlbum) return // Already highlighting this ViewHolder
// Album is no longer valid, clear out this ViewHolder.
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = null
if (album == currentAlbum) return
currentAlbum = album
if (album != null) {
// Use existing data instead of having to re-sort it.
val pos = currentList.indexOfFirst { item -> item.id == album.id && item is Album }
// Check if the ViewHolder if this album is visible, and highlight it if so.
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let {
currentAlbumHolder = it as Highlightable
currentAlbumHolder?.setHighlighted(true)
}
}
}
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = highlightItem(album, recycler)
}
/**
@ -145,91 +115,144 @@ class ArtistDetailAdapter(
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightSong(song: Song?, recycler: RecyclerView) {
if (song == currentSong) return // Already highlighting this ViewHolder
// Clear the current ViewHolder since it's invalid
currentSongHolder?.setHighlighted(false)
currentSongHolder = null
if (song == currentSong) return
currentSong = song
currentSongHolder?.setHighlighted(false)
currentSongHolder = highlightItem(song, recycler)
}
if (song != null) {
// Use existing data instead of having to re-sort it.
// We also have to account for the album count when searching for the ViewHolder.
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
// Check if the ViewHolder for this song is visible, if it is then highlight it.
// If the ViewHolder is not visible, then the adapter should take care of it if
// it does become visible.
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let {
currentSongHolder = it as Highlightable
currentSongHolder?.setHighlighted(true)
companion object {
private val DIFFER =
object : ItemDiffCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Artist && newItem is Artist ->
ArtistDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Album && newItem is Album ->
ArtistAlbumViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
ArtistSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem)
}
}
}
}
}
inner class ArtistDetailViewHolder(private val binding: ItemDetailBinding) :
BaseViewHolder<Artist>(binding) {
override fun onBind(data: Artist) {
val context = binding.root.context
binding.detailCover.apply {
bindArtistImage(data)
contentDescription =
context.getString(R.string.desc_artist_image, data.resolvedName)
}
binding.detailName.textSafe = data.resolvedName
// Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre.
binding.detailSubhead.textSafe =
data.songs
.groupBy { it.genre.resolvedName }
.entries
.maxByOrNull { it.value.size }
?.key
?: context.getString(R.string.def_genre)
binding.detailInfo.textSafe =
binding.context.getString(
R.string.fmt_two,
binding.context.getPluralSafe(R.plurals.fmt_album_count, data.albums.size),
binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size))
binding.detailPlayButton.setOnClickListener { playbackModel.playArtist(data, false) }
binding.detailShuffleButton.setOnClickListener { playbackModel.playArtist(data, true) }
}
}
inner class ArtistAlbumViewHolder(
private val binding: ItemParentBinding,
) : BaseViewHolder<Album>(binding, doOnClick, doOnLongClick), Highlightable {
override fun onBind(data: Album) {
binding.parentImage.bindAlbumCover(data)
binding.parentName.textSafe = data.resolvedName
binding.parentInfo.textSafe = binding.context.getString(R.string.fmt_number, data.year)
}
override fun setHighlighted(isHighlighted: Boolean) {
binding.parentName.isActivated = isHighlighted
}
}
inner class ArtistSongViewHolder(
private val binding: ItemSongBinding,
) : BaseViewHolder<Song>(binding, doOnSongClick, doOnLongClick), Highlightable {
override fun onBind(data: Song) {
binding.songAlbumCover.bindAlbumCover(data)
binding.songName.textSafe = data.resolvedName
binding.songInfo.textSafe = data.resolvedAlbumName
}
override fun setHighlighted(isHighlighted: Boolean) {
binding.songName.isActivated = isHighlighted
}
}
}
private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
BindingViewHolder<Artist, DetailItemListener>(binding.root) {
override fun bind(item: Artist, listener: DetailItemListener) {
binding.detailCover.bindArtistImage(item)
binding.detailName.textSafe = item.resolvedName
// Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre.
binding.detailSubhead.textSafe =
item.songs.groupBy { it.genre.resolvedName }.entries.maxByOrNull { it.value.size }?.key
?: binding.context.getString(R.string.def_genre)
binding.detailInfo.textSafe =
binding.context.getString(
R.string.fmt_two,
binding.context.getPluralSafe(R.plurals.fmt_album_count, item.albums.size),
binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size))
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
companion object {
val CREATOR =
object : Creator<ArtistDetailViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_ARTIST_DETAIL
override fun create(context: Context) =
ArtistDetailViewHolder(ItemDetailBinding.inflate(context.inflater))
}
val DIFFER = ArtistViewHolder.DIFFER
}
}
private class ArtistAlbumViewHolder
private constructor(
private val binding: ItemParentBinding,
) : BindingViewHolder<Album, MenuItemListener>(binding.root), Highlightable {
override fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bindAlbumCover(item)
binding.parentName.textSafe = item.resolvedName
binding.parentInfo.textSafe = binding.context.getString(R.string.fmt_number, item.year)
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
}
override fun setHighlighted(isHighlighted: Boolean) {
binding.parentName.isActivated = isHighlighted
}
companion object {
val CREATOR =
object : Creator<ArtistAlbumViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_ARTIST_ALBUM
override fun create(context: Context) =
ArtistAlbumViewHolder(ItemParentBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Album>() {
override fun areItemsTheSame(oldItem: Album, newItem: Album) =
oldItem.resolvedName == newItem.resolvedName && oldItem.year == newItem.year
}
}
}
private class ArtistSongViewHolder
private constructor(
private val binding: ItemSongBinding,
) : BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable {
override fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = item.resolvedName
binding.songInfo.textSafe = item.resolvedAlbumName
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
}
override fun setHighlighted(isHighlighted: Boolean) {
binding.songName.isActivated = isHighlighted
}
companion object {
val CREATOR =
object : Creator<ArtistSongViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_ARTIST_SONG
override fun create(context: Context) =
ArtistSongViewHolder(ItemSongBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.resolvedAlbumName == newItem.resolvedAlbumName
}
}
}

View file

@ -0,0 +1,153 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.recycler
import android.content.Context
import android.view.View
import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.ItemDiffCallback
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MultiAdapter
import org.oxycblt.auxio.ui.NewHeaderViewHolder
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getViewHolderAt
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.textSafe
abstract class DetailAdapter<L : DetailItemListener>(
listener: L,
diffCallback: DiffUtil.ItemCallback<Item>
) : MultiAdapter<L>(listener, diffCallback) {
abstract fun onHighlightViewHolder(viewHolder: Highlightable, item: Item)
protected inline fun <reified T : Item> highlightItem(
newItem: T?,
recycler: RecyclerView
): Highlightable? {
if (newItem == null) {
return null
}
// Use existing data instead of having to re-sort it.
// We also have to account for the album count when searching for the ViewHolder.
val pos = mCurrentList.indexOfFirst { item -> item.id == newItem.id && item is T }
// Check if the ViewHolder for this song is visible, if it is then highlight it.
// If the ViewHolder is not visible, then the adapter should take care of it if
// it does become visible.
val viewHolder = recycler.getViewHolderAt(pos)
return if (viewHolder is Highlightable) {
viewHolder.setHighlighted(true)
viewHolder
} else {
logW("ViewHolder intended to highlight was not Highlightable")
null
}
}
override fun getCreatorFromItem(item: Item) =
when (item) {
is Header -> NewHeaderViewHolder.CREATOR
is SortHeader -> SortHeaderViewHolder.CREATOR
else -> null
}
override fun getCreatorFromViewType(viewType: Int) =
when (viewType) {
NewHeaderViewHolder.CREATOR.viewType -> NewHeaderViewHolder.CREATOR
SortHeaderViewHolder.CREATOR.viewType -> SortHeaderViewHolder.CREATOR
else -> null
}
override fun onBind(viewHolder: RecyclerView.ViewHolder, item: Item, listener: L) {
when (item) {
is Header -> (viewHolder as NewHeaderViewHolder).bind(item, Unit)
is SortHeader -> (viewHolder as SortHeaderViewHolder).bind(item, listener)
}
if (viewHolder is Highlightable) {
onHighlightViewHolder(viewHolder, item)
}
}
companion object {
val DIFFER =
object : ItemDiffCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Header && newItem is Header ->
NewHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader ->
SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
else -> false
}
}
}
}
}
data class SortHeader(override val id: Long, @StringRes val string: Int) : Item()
class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
BindingViewHolder<SortHeader, DetailItemListener>(binding.root) {
override fun bind(item: SortHeader, listener: DetailItemListener) {
binding.headerTitle.textSafe = binding.context.getString(item.string)
binding.headerButton.apply {
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener(listener::onShowSortMenu)
}
}
companion object {
val CREATOR =
object : Creator<SortHeaderViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_SORT_HEADER
override fun create(context: Context) =
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<SortHeader>() {
override fun areItemsTheSame(oldItem: SortHeader, newItem: SortHeader) =
oldItem.string == newItem.string
}
}
}
/** Interface that allows the highlighting of certain ViewHolders */
interface Highlightable {
fun setHighlighted(isHighlighted: Boolean)
}
interface DetailItemListener : MenuItemListener {
fun onPlayParent()
fun onShuffleParent()
fun onShowSortMenu(anchor: View)
}

View file

@ -17,9 +17,7 @@
package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
@ -27,70 +25,65 @@ import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.coil.bindGenreImage
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.ItemDiffCallback
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe
/**
* An adapter for displaying the [Song]s of a genre.
* An adapter for displaying genre information and it's children.
* @author OxygenCobalt
*/
class GenreDetailAdapter(
private val playbackModel: PlaybackViewModel,
private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: Song) -> Unit
) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback()) {
class GenreDetailAdapter(listener: DetailItemListener) :
DetailAdapter<DetailItemListener>(listener, DIFFER) {
private var currentSong: Song? = null
private var currentHolder: Highlightable? = null
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is Genre -> IntegerTable.ITEM_TYPE_GENRE_DETAIL
is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER
is Song -> IntegerTable.ITEM_TYPE_GENRE_SONG
else -> -1
}
}
override fun getCreatorFromItem(item: Item) =
super.getCreatorFromItem(item)
?: when (item) {
is Genre -> GenreDetailViewHolder.CREATOR
is Song -> GenreSongViewHolder.CREATOR
else -> null
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
IntegerTable.ITEM_TYPE_GENRE_DETAIL ->
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context)
IntegerTable.ITEM_TYPE_GENRE_SONG ->
GenreSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
else -> error("Bad ViewHolder item type $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = getItem(position)
override fun getCreatorFromViewType(viewType: Int) =
super.getCreatorFromViewType(viewType)
?: when (viewType) {
GenreDetailViewHolder.CREATOR.viewType -> GenreDetailViewHolder.CREATOR
GenreSongViewHolder.CREATOR.viewType -> GenreSongViewHolder.CREATOR
else -> null
}
override fun onBind(
viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: DetailItemListener
) {
super.onBind(viewHolder, item, listener)
when (item) {
is Genre -> (holder as GenreDetailViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
is Song -> (holder as GenreSongViewHolder).bind(item)
is Genre -> (viewHolder as GenreDetailViewHolder).bind(item, listener)
is Song -> (viewHolder as GenreSongViewHolder).bind(item, listener)
else -> {}
}
}
if (holder is Highlightable) {
if (item.id == currentSong?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted
currentHolder?.setHighlighted(false)
currentHolder = holder
holder.setHighlighted(true)
} else {
holder.setHighlighted(false)
}
override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) {
// If the item corresponds to a currently playing song/album then highlight it
if (item.id == currentSong?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted
currentHolder?.setHighlighted(false)
currentHolder = viewHolder
viewHolder.setHighlighted(true)
} else {
viewHolder.setHighlighted(false)
}
}
@ -99,64 +92,89 @@ class GenreDetailAdapter(
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightSong(song: Song?, recycler: RecyclerView) {
if (song == currentSong) return // Already highlighting this ViewHolder
// Clear the current ViewHolder since it's invalid
currentHolder?.setHighlighted(false)
currentHolder = null
if (song == currentSong) return
currentSong = song
currentHolder?.setHighlighted(false)
currentHolder = highlightItem(song, recycler)
}
if (song != null) {
// Use existing data instead of having to re-sort it.
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song }
// Check if the ViewHolder for this song is visible, if it is then highlight it.
// If the ViewHolder is not visible, then the adapter should take care of it if
// it does become visible.
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let {
currentHolder = it as Highlightable
currentHolder?.setHighlighted(true)
companion object {
val DIFFER =
object : ItemDiffCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Genre && newItem is Genre ->
GenreDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
GenreSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem)
}
}
}
}
}
inner class GenreDetailViewHolder(private val binding: ItemDetailBinding) :
BaseViewHolder<Genre>(binding) {
override fun onBind(data: Genre) {
val context = binding.root.context
binding.detailCover.apply {
bindGenreImage(data)
contentDescription = context.getString(R.string.desc_genre_image, data.resolvedName)
}
binding.detailName.textSafe = data.resolvedName
binding.detailSubhead.textSafe =
binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size)
binding.detailInfo.textSafe = data.totalDuration
binding.detailPlayButton.setOnClickListener { playbackModel.playGenre(data, false) }
binding.detailShuffleButton.setOnClickListener { playbackModel.playGenre(data, true) }
}
}
/** The Shared ViewHolder for a [Song]. Instantiation should be done with [from]. */
inner class GenreSongViewHolder
constructor(
private val binding: ItemSongBinding,
) : BaseViewHolder<Song>(binding, doOnClick, doOnLongClick), Highlightable {
override fun onBind(data: Song) {
binding.songAlbumCover.bindAlbumCover(data)
binding.songName.textSafe = data.resolvedName
binding.songInfo.textSafe = data.resolvedArtistName
}
override fun setHighlighted(isHighlighted: Boolean) {
binding.songName.isActivated = isHighlighted
}
}
}
private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
BindingViewHolder<Genre, DetailItemListener>(binding.root) {
override fun bind(item: Genre, listener: DetailItemListener) {
binding.detailCover.bindGenreImage(item)
binding.detailName.textSafe = item.resolvedName
binding.detailSubhead.textSafe =
binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size)
binding.detailInfo.textSafe = item.totalDuration
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
companion object {
val CREATOR =
object : Creator<GenreDetailViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_GENRE_DETAIL
override fun create(context: Context) =
GenreDetailViewHolder(ItemDetailBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Genre>() {
override fun areItemsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.songs.size == newItem.songs.size &&
oldItem.totalDuration == newItem.totalDuration
}
}
}
class GenreSongViewHolder private constructor(private val binding: ItemSongBinding) :
BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable {
override fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = item.resolvedName
binding.songInfo.textSafe = item.resolvedArtistName
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
}
override fun setHighlighted(isHighlighted: Boolean) {
binding.songName.isActivated = isHighlighted
}
companion object {
val CREATOR =
object : Creator<GenreSongViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_GENRE_SONG
override fun create(context: Context) =
GenreSongViewHolder(ItemSongBinding.inflate(context.inflater))
}
val DIFFER = SongViewHolder.DIFFER
}
}

View file

@ -1,23 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.recycler
/** Interface that allows the highlighting of certain ViewHolders */
interface Highlightable {
fun setHighlighted(isHighlighted: Boolean)
}

View file

@ -136,24 +136,33 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
removeCallbacks(hideThumbRunnable)
showScrollbar()
showPopup()
onDragListener?.onFastScrollStart()
} else {
postAutoHideScrollbar()
hidePopup()
onDragListener?.onFastScrollStop()
}
onDragListener?.invoke(value)
}
private val tRect = Rect()
interface PopupProvider {
fun getPopup(pos: Int): String?
}
/** Callback to provide a string to be shown on the popup when an item is passed */
var popupProvider: ((Int) -> String)? = null
var popupProvider: PopupProvider? = null
interface OnFastScrollListener {
fun onFastScrollStart()
fun onFastScrollStop()
}
/**
* A listener for when a drag event occurs. The value will be true if a drag has begun, and
* false if a drag ended.
*/
var onDragListener: ((Boolean) -> Unit)? = null
var onDragListener: OnFastScrollListener? = null
init {
overlay.add(thumbView)
@ -186,8 +195,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() {
// FIXME: Make the way we lay out views less of a hacky mess. Perhaps consider
// overlaying views or turning this into a ViewGroup.
updateScrollbarState()
thumbView.layoutDirection = layoutDirection
@ -207,13 +214,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
val firstPos = firstAdapterPos
val popupText =
if (firstPos != NO_POSITION) {
popupProvider?.invoke(firstPos)?.ifEmpty { null }
popupProvider?.getPopup(firstPos)?.ifEmpty { null }
} else {
null
}
// Lay out the popup view
popupView.isInvisible = popupText == null
if (popupText != null) {
@ -370,6 +375,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun scrollTo(offset: Int) {
if (childCount == 0) {
return
}
stopScroll()
val trueOffset = offset - paddingTop

View file

@ -17,16 +17,18 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.ui.AlbumViewHolder
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.ui.sliceArticle
@ -35,50 +37,43 @@ import org.oxycblt.auxio.ui.sliceArticle
* A [HomeListFragment] for showing a list of [Album]s.
* @author
*/
class AlbumListFragment : HomeListFragment() {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
val homeAdapter =
AlbumAdapter(
doOnClick = { album ->
findNavController().navigate(HomeFragmentDirections.actionShowAlbum(album.id))
},
::newMenu)
class AlbumListFragment : HomeListFragment<Album>() {
override val recyclerId: Int = R.id.home_album_list
override val homeAdapter = AlbumAdapter(this)
override val homeData: LiveData<List<Album>>
get() = homeModel.albums
setupRecycler(R.id.home_album_list, homeAdapter, homeModel.albums)
override fun getPopup(pos: Int): String? {
val album = homeModel.albums.value!![pos]
// Change how we display the popup depending on the mode.
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) {
// By Name -> Use Name
is Sort.ByName -> album.resolvedName.sliceArticle().first().uppercase()
// By Artist -> Use Artist Name
is Sort.ByArtist -> album.artist.resolvedName.sliceArticle().first().uppercase()
// Year -> Use Full Year
is Sort.ByYear -> album.year?.toString() ?: getString(R.string.def_date)
// Unsupported sort, error gracefully
else -> null
}
}
override val listPopupProvider: (Int) -> String
get() = { idx ->
val album = homeModel.albums.value!![idx]
override fun onItemClick(item: Item) {
check(item is Album)
findNavController().navigate(HomeFragmentDirections.actionShowAlbum(item.id))
}
// Change how we display the popup depending on the mode.
when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) {
// By Name -> Use Name
is Sort.ByName -> album.resolvedName.sliceArticle().first().uppercase()
override fun onOpenMenu(item: Item, anchor: View) {
newMenu(anchor, item)
}
// By Artist -> Use Artist Name
is Sort.ByArtist -> album.artist.resolvedName.sliceArticle().first().uppercase()
// Year -> Use Full Year
is Sort.ByYear -> album.year?.toString() ?: getString(R.string.def_date)
// Unsupported sort, error gracefully
else -> ""
}
}
class AlbumAdapter(
private val doOnClick: (data: Album) -> Unit,
private val doOnLongClick: (view: View, data: Album) -> Unit,
) : HomeAdapter<Album, AlbumViewHolder>() {
override fun getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AlbumViewHolder {
return AlbumViewHolder.from(parent.context, doOnClick, doOnLongClick)
}
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
holder.bind(data[position])
}
class AlbumAdapter(listener: MenuItemListener) :
MonoAdapter<Album, MenuItemListener, AlbumViewHolder>(listener, AlbumViewHolder.DIFFER) {
override val creator: BindingViewHolder.Creator<AlbumViewHolder>
get() = AlbumViewHolder.CREATOR
}
}

View file

@ -17,15 +17,16 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ArtistViewHolder
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.ui.sliceArticle
@ -33,35 +34,26 @@ import org.oxycblt.auxio.ui.sliceArticle
* A [HomeListFragment] for showing a list of [Artist]s.
* @author
*/
class ArtistListFragment : HomeListFragment() {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
val homeAdapter =
ArtistAdapter(
doOnClick = { artist ->
findNavController().navigate(HomeFragmentDirections.actionShowArtist(artist.id))
},
::newMenu)
class ArtistListFragment : HomeListFragment<Artist>() {
override val recyclerId: Int = R.id.home_artist_list
override val homeAdapter = ArtistAdapter(this)
override val homeData: LiveData<List<Artist>>
get() = homeModel.artists
setupRecycler(R.id.home_artist_list, homeAdapter, homeModel.artists)
override fun getPopup(pos: Int) =
homeModel.artists.value!![pos].resolvedName.sliceArticle().first().uppercase()
override fun onItemClick(item: Item) {
check(item is Artist)
findNavController().navigate(HomeFragmentDirections.actionShowArtist(item.id))
}
override val listPopupProvider: (Int) -> String
get() = { idx ->
homeModel.artists.value!![idx].resolvedName.sliceArticle().first().uppercase()
}
override fun onOpenMenu(item: Item, anchor: View) {
newMenu(anchor, item)
}
class ArtistAdapter(
private val doOnClick: (data: Artist) -> Unit,
private val doOnLongClick: (view: View, data: Artist) -> Unit,
) : HomeAdapter<Artist, ArtistViewHolder>() {
override fun getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder {
return ArtistViewHolder.from(parent.context, doOnClick, doOnLongClick)
}
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
holder.bind(data[position])
}
class ArtistAdapter(listener: MenuItemListener) :
MonoAdapter<Artist, MenuItemListener, ArtistViewHolder>(listener, ArtistViewHolder.DIFFER) {
override val creator = ArtistViewHolder.CREATOR
}
}

View file

@ -17,52 +17,43 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.ui.GenreViewHolder
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.ui.sliceArticle
import org.oxycblt.auxio.util.context
/**
* A [HomeListFragment] for showing a list of [Genre]s.
* @author
*/
class GenreListFragment : HomeListFragment() {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
val homeAdapter =
GenreAdapter(
doOnClick = { Genre ->
findNavController().navigate(HomeFragmentDirections.actionShowGenre(Genre.id))
},
::newMenu)
class GenreListFragment : HomeListFragment<Genre>() {
override val recyclerId = R.id.home_genre_list
override val homeAdapter = GenreAdapter(this)
override val homeData: LiveData<List<Genre>>
get() = homeModel.genres
setupRecycler(R.id.home_genre_list, homeAdapter, homeModel.genres)
override fun getPopup(pos: Int) =
homeModel.genres.value!![pos].resolvedName.sliceArticle().first().uppercase()
override fun onItemClick(item: Item) {
check(item is Genre)
findNavController().navigate(HomeFragmentDirections.actionShowGenre(item.id))
}
override val listPopupProvider: (Int) -> String
get() = { idx ->
homeModel.genres.value!![idx].resolvedName.sliceArticle().first().uppercase()
}
override fun onOpenMenu(item: Item, anchor: View) {
newMenu(anchor, item)
}
class GenreAdapter(
private val doOnClick: (data: Genre) -> Unit,
private val doOnLongClick: (view: View, data: Genre) -> Unit,
) : HomeAdapter<Genre, GenreViewHolder>() {
override fun getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GenreViewHolder {
return GenreViewHolder.from(parent.context, doOnClick, doOnLongClick)
}
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
holder.bind(data[position])
}
class GenreAdapter(listener: MenuItemListener) :
MonoAdapter<Genre, MenuItemListener, GenreViewHolder>(listener, GenreViewHolder.DIFFER) {
override val creator = GenreViewHolder.CREATOR
}
}

View file

@ -17,17 +17,19 @@
package org.oxycblt.auxio.home.list
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import androidx.annotation.IdRes
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.applySpans
@ -35,49 +37,52 @@ import org.oxycblt.auxio.util.applySpans
* A Base [Fragment] implementing the base features shared across all list fragments in the home UI.
* @author OxygenCobalt
*/
abstract class HomeListFragment : ViewBindingFragment<FragmentHomeListBinding>() {
abstract class HomeListFragment<T : Item> :
ViewBindingFragment<FragmentHomeListBinding>(),
MenuItemListener,
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.OnFastScrollListener {
/** The popup provider to use for the fast scroller view. */
abstract val listPopupProvider: (Int) -> String
abstract val recyclerId: Int
abstract val homeAdapter:
MonoAdapter<T, MenuItemListener, out BindingViewHolder<T, MenuItemListener>>
abstract val homeData: LiveData<List<T>>
protected val homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
protected fun <T : Item, VH : RecyclerView.ViewHolder> setupRecycler(
@IdRes uniqueId: Int,
homeAdapter: HomeAdapter<T, VH>,
homeData: LiveData<List<T>>,
) {
requireBinding().homeRecycler.apply {
id = uniqueId
adapter = homeAdapter
setHasFixedSize(true)
applySpans()
popupProvider = listPopupProvider
onDragListener = homeModel::updateFastScrolling
}
homeData.observe(viewLifecycleOwner, homeAdapter::updateData)
}
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
homeModel.updateFastScrolling(false)
}
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
binding.homeRecycler.apply {
id = recyclerId
adapter = homeAdapter
applySpans()
}
abstract class HomeAdapter<T : Item, VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
protected var data = listOf<T>()
binding.homeRecycler.popupProvider = this
binding.homeRecycler.onDragListener = this
@SuppressLint("NotifyDataSetChanged")
fun updateData(newData: List<T>) {
data = newData
// notifyDataSetChanged here is okay, as we have no idea how the layout changed when
// we re-sort and ListAdapter causes the scroll position to get messed up
notifyDataSetChanged()
homeData.observe(viewLifecycleOwner) { list ->
homeAdapter.submitListHard(list.toMutableList())
}
}
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
homeModel.updateFastScrolling(false)
binding.homeRecycler.apply {
adapter = null
popupProvider = null
onDragListener = null
}
}
override fun onFastScrollStart() {
homeModel.updateFastScrolling(true)
}
override fun onFastScrollStop() {
homeModel.updateFastScrolling(false)
}
}

View file

@ -17,13 +17,14 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LiveData
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu
@ -33,47 +34,44 @@ import org.oxycblt.auxio.ui.sliceArticle
* A [HomeListFragment] for showing a list of [Song]s.
* @author
*/
class SongListFragment : HomeListFragment() {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
val homeAdapter = SongsAdapter(doOnClick = playbackModel::playSong, ::newMenu)
setupRecycler(R.id.home_song_list, homeAdapter, homeModel.songs)
class SongListFragment : HomeListFragment<Song>() {
override val recyclerId = R.id.home_song_list
override val homeAdapter = SongsAdapter(this)
override val homeData: LiveData<List<Song>>
get() = homeModel.songs
override fun getPopup(pos: Int): String {
val song = homeModel.songs.value!![pos]
// Change how we display the popup depending on the mode.
// We don't use the more correct resolve(Model)Name here, as sorts are largely
// based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) {
// Name -> Use name
is Sort.ByName -> song.resolvedName.sliceArticle().first().uppercase()
// Artist -> Use Artist Name
is Sort.ByArtist -> song.album.artist.resolvedName.sliceArticle().first().uppercase()
// Album -> Use Album Name
is Sort.ByAlbum -> song.album.resolvedName.sliceArticle().first().uppercase()
// Year -> Use Full Year
is Sort.ByYear -> song.album.year?.toString() ?: getString(R.string.def_date)
}
}
override val listPopupProvider: (Int) -> String
get() = { idx ->
val song = homeModel.songs.value!![idx]
override fun onItemClick(item: Item) {
check(item is Song)
playbackModel.playSong(item)
}
// Change how we display the popup depending on the mode.
// We don't use the more correct resolve(Model)Name here, as sorts are largely
// based off the names of the parent objects and not the child objects.
when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) {
// Name -> Use name
is Sort.ByName -> song.resolvedName.sliceArticle().first().uppercase()
override fun onOpenMenu(item: Item, anchor: View) {
newMenu(anchor, item)
}
// Artist -> Use Artist Name
is Sort.ByArtist ->
song.album.artist.resolvedName.sliceArticle().first().uppercase()
// Album -> Use Album Name
is Sort.ByAlbum -> song.album.resolvedName.sliceArticle().first().uppercase()
// Year -> Use Full Year
is Sort.ByYear -> song.album.year?.toString() ?: getString(R.string.def_date)
}
}
inner class SongsAdapter(
private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (view: View, data: Song) -> Unit,
) : HomeAdapter<Song, SongViewHolder>() {
override fun getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder {
return SongViewHolder.from(parent.context, doOnClick, doOnLongClick)
}
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
holder.bind(data[position])
}
inner class SongsAdapter(listener: MenuItemListener) :
MonoAdapter<Song, MenuItemListener, SongViewHolder>(listener, SongViewHolder.DIFFER) {
override val creator = SongViewHolder.CREATOR
}
}

View file

@ -20,18 +20,10 @@ package org.oxycblt.auxio.music
import android.content.ContentUris
import android.net.Uri
import android.provider.MediaStore
import android.view.View
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.oxycblt.auxio.ui.Item
// --- MUSIC MODELS ---
/** The base for all items in Auxio. */
sealed class Item {
/** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */
abstract val id: Long
}
/** [Item] variant that represents a music item. */
sealed class Music : Item() {
/** The raw name of this item. */
@ -245,49 +237,3 @@ data class Genre(
val totalDuration: String
get() = songs.sumOf { it.seconds }.toDuration(false)
}
/** A data object used solely for the "Header" UI element. */
data class Header(
override val id: Long,
/** The string resource used for the header. */
@StringRes val string: Int
) : Item()
/**
* A data object used for an action header. Like [Header], but with a button.
* @see Header
*/
data class ActionHeader(
override val id: Long,
/** The string resource used for the header. */
@StringRes val string: Int,
/** The icon resource used for the header action. */
@DrawableRes val icon: Int,
/** The string resource used for the header action's content description. */
@StringRes val desc: Int,
/** A callback for when this item is clicked. */
val onClick: (View) -> Unit,
) : Item() {
// All lambdas are not equal to each-other, so we override equals/hashCode and exclude them.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ActionHeader) return false
if (id != other.id) return false
if (string != other.string) return false
if (icon != other.icon) return false
if (desc != other.desc) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + string.hashCode()
result = 31 * result + icon
result = 31 * result + desc
return result
}
}

View file

@ -357,10 +357,8 @@ class MusicLoader {
while (cursor.moveToNext()) {
// Genre names can be a normal name, an ID3v2 constant, or null. Normal names
// are
// resolved as usual, but null values don't make sense and are often junk
// anyway,
// so we skip genres that have them.
// are resolved as usual, but null values don't make sense and are often junk
// anyway, so we skip genres that have them.
val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue
val resolvedName = name.genreNameCompat ?: name

View file

@ -18,137 +18,89 @@
package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder
import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.stateList
import org.oxycblt.auxio.util.textSafe
/**
* The single adapter for both the Next Queue and the User Queue.
* @param touchHelper The [ItemTouchHelper] ***containing*** [QueueDragCallback] to be used
* @author OxygenCobalt
*/
class QueueAdapter(private val touchHelper: ItemTouchHelper) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var data = mutableListOf<Item>()
private var listDiffer = AsyncListDiffer(this, DiffCallback())
class NewQueueAdapter(listener: QueueItemListener) :
MonoAdapter<Song, QueueItemListener, QueueSongViewHolder>(
listener, QueueSongViewHolder.DIFFER) {
override val creator = QueueSongViewHolder.CREATOR
}
override fun getItemCount(): Int = data.size
interface QueueItemListener {
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
}
override fun getItemViewType(position: Int): Int {
return when (data[position]) {
is Song -> IntegerTable.ITEM_TYPE_QUEUE_SONG
is Header -> IntegerTable.ITEM_TYPE_HEADER
is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER
else -> -1
}
}
class QueueSongViewHolder
private constructor(
private val binding: ItemQueueSongBinding,
) : BindingViewHolder<Song, QueueItemListener>(binding.root) {
val bodyView: View
get() = binding.body
val backgroundView: View
get() = binding.background
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
IntegerTable.ITEM_TYPE_QUEUE_SONG ->
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
IntegerTable.ITEM_TYPE_HEADER -> HeaderViewHolder.from(parent.context)
IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = data[position]) {
is Song -> (holder as QueueSongViewHolder).bind(item)
is Header -> (holder as HeaderViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
else -> logE("Bad data given to QueueAdapter")
}
}
/**
* Submit data using [AsyncListDiffer]. **Only use this if you have no idea what changes
* occurred to the data**
*/
fun submitList(newData: MutableList<Item>) {
if (data != newData) {
data = newData
listDiffer.submitList(newData)
}
}
/** Move Items. Used since [submitList] will cause QueueAdapter to freak out. */
fun moveItems(adapterFrom: Int, adapterTo: Int) {
data.add(adapterTo, data.removeAt(adapterFrom))
notifyItemMoved(adapterFrom, adapterTo)
}
/** Remove an item. Used since [submitList] will cause QueueAdapter to freak out. */
fun removeItem(adapterIndex: Int) {
data.removeAt(adapterIndex)
notifyItemRemoved(adapterIndex)
}
/** Generic ViewHolder for a queue song */
inner class QueueSongViewHolder(
private val binding: ItemQueueSongBinding,
) : BaseViewHolder<Song>(binding) {
val bodyView: View
get() = binding.body
val backgroundView: View
get() = binding.background
init {
binding.body.background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = (binding.body.background as ColorDrawable).color.stateList
}
binding.root.disableDropShadowCompat()
}
@SuppressLint("ClickableViewAccessibility")
override fun onBind(data: Song) {
binding.songAlbumCover.bindAlbumCover(data)
binding.songName.textSafe = data.resolvedName
binding.songInfo.textSafe = data.resolvedArtistName
binding.background.isInvisible = true
binding.songName.requestLayout()
binding.songInfo.requestLayout()
// Roll our own drag handlers as the default ones suck
binding.songDragHandle.setOnTouchListener { _, motionEvent ->
binding.songDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(this)
true
} else false
init {
binding.body.background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = (binding.body.background as ColorDrawable).color.stateList
}
binding.body.setOnLongClickListener {
touchHelper.startDrag(this)
binding.root.disableDropShadowCompat()
}
@SuppressLint("ClickableViewAccessibility")
override fun bind(item: Song, listener: QueueItemListener) {
binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = item.resolvedName
binding.songInfo.textSafe = item.resolvedArtistName
binding.background.isInvisible = true
binding.songName.requestLayout()
binding.songInfo.requestLayout()
// Roll our own drag handlers as the default ones suck
binding.songDragHandle.setOnTouchListener { _, motionEvent ->
binding.songDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onPickUp(this)
true
}
} else false
}
binding.body.setOnLongClickListener {
listener.onPickUp(this)
true
}
}
companion object {
val CREATOR =
object : Creator<QueueSongViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_QUEUE_SONG
override fun create(context: Context): QueueSongViewHolder =
QueueSongViewHolder(ItemQueueSongBinding.inflate(context.inflater))
}
val DIFFER = SongViewHolder.DIFFER
}
}

View file

@ -38,8 +38,10 @@ import org.oxycblt.auxio.util.logD
* hot garbage. This shouldn't have *too many* UI bugs. I hope.
* @author OxygenCobalt
*/
class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() {
private lateinit var queueAdapter: QueueAdapter
class QueueDragCallback(
private val playbackModel: PlaybackViewModel,
private val queueAdapter: NewQueueAdapter
) : ItemTouchHelper.Callback() {
private var shouldLift = true
override fun getMovementFlags(
@ -83,7 +85,7 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
// themselves when being dragged. Too bad google's implementation of this doesn't even
// work! To emulate it on my own, I check if this child is in a drag state and then animate
// an elevation change.
val holder = viewHolder as QueueAdapter.QueueSongViewHolder
val holder = viewHolder as QueueSongViewHolder
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
logD("Lifting queue item")
@ -122,7 +124,7 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
// When an elevated item is cleared, we reset the elevation using another animation.
val holder = viewHolder as QueueAdapter.QueueSongViewHolder
val holder = viewHolder as QueueSongViewHolder
if (holder.itemView.translationZ != 0f) {
logD("Dropping queue item")
@ -163,14 +165,6 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
override fun isLongPressDragEnabled(): Boolean = false
/**
* Add the queue adapter to this callback. Done because there's a circular dependency between
* the two objects
*/
fun addQueueAdapter(adapter: QueueAdapter) {
queueAdapter = adapter
}
companion object {
const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25

View file

@ -23,56 +23,68 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.requireAttached
/**
* A [Fragment] that shows the queue and enables editing as well.
* @author OxygenCobalt
*/
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>() {
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemListener {
private val playbackModel: PlaybackViewModel by activityViewModels()
private var lastShuffle: Boolean? = null
private var queueAdapter = NewQueueAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var callback: QueueDragCallback? = null
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentQueueBinding, savedInstanceState: Bundle?) {
// TODO: Merge ItemTouchHelper with QueueAdapter
val callback = QueueDragCallback(playbackModel)
val helper = ItemTouchHelper(callback)
val queueAdapter = QueueAdapter(helper)
callback.addQueueAdapter(queueAdapter)
binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.queueRecycler.apply {
setHasFixedSize(true)
adapter = queueAdapter
helper.attachToRecyclerView(this)
requireTouchHelper().attachToRecyclerView(this)
}
// --- VIEWMODEL SETUP ----
lastShuffle = playbackModel.isShuffling.value
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
// Try to prevent the queue adapter from going spastic during reshuffle events
// by just scrolling back to the top.
if (isShuffling != lastShuffle) {
logD("Reshuffle event, scrolling to top")
lastShuffle = isShuffling
binding.queueRecycler.scrollToPosition(0)
}
playbackModel.nextUp.observe(viewLifecycleOwner, ::updateQueue)
}
override fun onDestroyBinding(binding: FragmentQueueBinding) {
super.onDestroyBinding(binding)
binding.queueRecycler.adapter = null
touchHelper = null
callback = null
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
requireTouchHelper().startDrag(viewHolder)
}
private fun updateQueue(queue: List<Song>) {
if (queue.isEmpty()) {
findNavController().navigateUp()
return
}
playbackModel.nextUp.observe(viewLifecycleOwner) { queue ->
if (queue.isEmpty()) {
findNavController().navigateUp()
return@observe
}
queueAdapter.submitList(queue.toMutableList())
}
queueAdapter.submitList(queue.toMutableList())
private fun requireTouchHelper(): ItemTouchHelper {
requireAttached()
val instance = touchHelper
if (instance != null) {
return instance
}
val newCallback = QueueDragCallback(playbackModel, queueAdapter)
val newInstance = ItemTouchHelper(newCallback)
callback = newCallback
touchHelper = newInstance
return newInstance
}
}

View file

@ -17,68 +17,76 @@
package org.oxycblt.auxio.search
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.AlbumViewHolder
import org.oxycblt.auxio.ui.ArtistViewHolder
import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.GenreViewHolder
import org.oxycblt.auxio.ui.HeaderViewHolder
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.ItemDiffCallback
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MultiAdapter
import org.oxycblt.auxio.ui.NewHeaderViewHolder
import org.oxycblt.auxio.ui.SongViewHolder
/**
* A Multi-ViewHolder adapter that displays the results of a search query.
* @author OxygenCobalt
*/
class SearchAdapter(
private val doOnClick: (data: Music) -> Unit,
private val doOnLongClick: (view: View, data: Music) -> Unit
) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback<Item>()) {
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is Genre -> IntegerTable.ITEM_TYPE_GENRE
is Artist -> IntegerTable.ITEM_TYPE_ARTIST
is Album -> IntegerTable.ITEM_TYPE_ALBUM
is Song -> IntegerTable.ITEM_TYPE_SONG
is Header -> IntegerTable.ITEM_TYPE_HEADER
else -> -1
class NeoSearchAdapter(listener: MenuItemListener) :
MultiAdapter<MenuItemListener>(listener, DIFFER) {
override fun getCreatorFromItem(item: Item) =
when (item) {
is Song -> SongViewHolder.CREATOR
is Album -> AlbumViewHolder.CREATOR
is Artist -> ArtistViewHolder.CREATOR
is Genre -> GenreViewHolder.CREATOR
is Header -> NewHeaderViewHolder.CREATOR
else -> null
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
IntegerTable.ITEM_TYPE_GENRE ->
GenreViewHolder.from(parent.context, doOnClick, doOnLongClick)
IntegerTable.ITEM_TYPE_ARTIST ->
ArtistViewHolder.from(parent.context, doOnClick, doOnLongClick)
IntegerTable.ITEM_TYPE_ALBUM ->
AlbumViewHolder.from(parent.context, doOnClick, doOnLongClick)
IntegerTable.ITEM_TYPE_SONG ->
SongViewHolder.from(parent.context, doOnClick, doOnLongClick)
IntegerTable.ITEM_TYPE_HEADER -> HeaderViewHolder.from(parent.context)
else -> error("Invalid ViewHolder item type")
override fun getCreatorFromViewType(viewType: Int) =
when (viewType) {
SongViewHolder.CREATOR.viewType -> SongViewHolder.CREATOR
AlbumViewHolder.CREATOR.viewType -> AlbumViewHolder.CREATOR
ArtistViewHolder.CREATOR.viewType -> ArtistViewHolder.CREATOR
GenreViewHolder.CREATOR.viewType -> GenreViewHolder.CREATOR
NewHeaderViewHolder.CREATOR.viewType -> NewHeaderViewHolder.CREATOR
else -> null
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = getItem(position)) {
is Genre -> (holder as GenreViewHolder).bind(item)
is Artist -> (holder as ArtistViewHolder).bind(item)
is Album -> (holder as AlbumViewHolder).bind(item)
is Song -> (holder as SongViewHolder).bind(item)
is Header -> (holder as HeaderViewHolder).bind(item)
override fun onBind(
viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: MenuItemListener
) {
when (item) {
is Song -> (viewHolder as SongViewHolder).bind(item, listener)
is Album -> (viewHolder as AlbumViewHolder).bind(item, listener)
is Artist -> (viewHolder as ArtistViewHolder).bind(item, listener)
is Genre -> (viewHolder as GenreViewHolder).bind(item, listener)
is Header -> (viewHolder as NewHeaderViewHolder).bind(item, Unit)
else -> {}
}
}
companion object {
private val DIFFER =
object : ItemDiffCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Song && newItem is Song ->
SongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Album && newItem is Album ->
AlbumViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Artist && newItem is Artist ->
ArtistViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Genre && newItem is Genre ->
GenreViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Header && newItem is Header ->
NewHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
else -> false
}
}
}
}

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.search
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.core.view.isInvisible
import androidx.core.view.postDelayed
@ -33,44 +34,42 @@ import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.requireAttached
/**
* A [Fragment] that allows for the searching of the entire music library.
* @author OxygenCobalt
*/
class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
class SearchFragment : ViewBindingFragment<FragmentSearchBinding>(), MenuItemListener {
// SearchViewModel is only scoped to this Fragment
private val searchModel: SearchViewModel by viewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val searchAdapter = NeoSearchAdapter(this)
private var imm: InputMethodManager? = null
private var launchedKeyboard = false
override fun onCreateBinding(inflater: LayoutInflater) = FragmentSearchBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) {
val imm = requireContext().getSystemServiceSafe(InputMethodManager::class)
val searchAdapter =
SearchAdapter(doOnClick = { item -> onItemSelection(item, imm) }, ::newMenu)
// --- UI SETUP --
binding.searchToolbar.apply {
menu.findItem(searchModel.filterMode?.itemId ?: R.id.option_filter_all).isChecked = true
setNavigationOnClickListener {
imm.hide()
requireImm().hide()
findNavController().navigateUp()
}
@ -94,7 +93,9 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown
requestFocus()
postDelayed(200) { imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) }
postDelayed(200) {
requireImm().showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
launchedKeyboard = true
}
@ -107,13 +108,11 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
// --- VIEWMODEL SETUP ---
searchModel.searchResults.observe(viewLifecycleOwner) { results ->
updateResults(results, searchAdapter)
}
searchModel.searchResults.observe(viewLifecycleOwner, ::updateResults)
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
handleNavigation(item)
imm.hide()
requireImm().hide()
}
}
@ -122,10 +121,47 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
searchModel.setNavigating(false)
}
private fun updateResults(results: List<Item>, searchAdapter: SearchAdapter) {
override fun onDestroyBinding(binding: FragmentSearchBinding) {
super.onDestroyBinding(binding)
binding.searchRecycler.adapter = null
imm = null
}
override fun onItemClick(item: Item) {
if (item is Song) {
playbackModel.playSong(item)
return
}
if (item is MusicParent && !searchModel.isNavigating) {
searchModel.setNavigating(true)
logD("Navigating to the detail fragment for ${item.rawName}")
findNavController()
.navigate(
when (item) {
is Genre -> SearchFragmentDirections.actionShowGenre(item.id)
is Artist -> SearchFragmentDirections.actionShowArtist(item.id)
is Album -> SearchFragmentDirections.actionShowAlbum(item.id)
})
requireImm().hide()
}
}
override fun onOpenMenu(item: Item, anchor: View) {
newMenu(anchor, item)
}
private fun updateResults(results: List<Item>) {
if (isDetached) {
error("Fragment not attached to activity")
}
val binding = requireBinding()
searchAdapter.submitList(results) {
searchAdapter.submitList(results.toMutableList()) {
// I would make it so that the position is only scrolled back to the top when
// the query actually changes instead of once every re-creation event, but sadly
// that doesn't seem possible.
@ -146,41 +182,18 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
})
}
private fun requireImm(): InputMethodManager {
requireAttached()
val instance = imm
if (instance != null) {
return instance
}
val newInstance = requireContext().getSystemServiceSafe(InputMethodManager::class)
imm = newInstance
return newInstance
}
private fun InputMethodManager.hide() {
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
}
/**
* Function that handles when an [item] is selected. Handles all datatypes that are selectable.
*/
private fun onItemSelection(item: Music, imm: InputMethodManager) {
if (item is Song) {
playbackModel.playSong(item)
return
}
if (!searchModel.isNavigating) {
searchModel.setNavigating(true)
logD("Navigating to the detail fragment for ${item.rawName}")
findNavController()
.navigate(
when (item) {
is Genre -> SearchFragmentDirections.actionShowGenre(item.id)
is Artist -> SearchFragmentDirections.actionShowArtist(item.id)
is Album -> SearchFragmentDirections.actionShowAlbum(item.id)
// If given model wasn't valid, then reset the navigation status
// and abort the navigation.
else -> {
searchModel.setNavigating(false)
return
}
})
imm.hide()
}
}
}

View file

@ -25,12 +25,12 @@ import androidx.lifecycle.viewModelScope
import java.text.Normalizer
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD

View file

@ -30,7 +30,6 @@ import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.showToast

View file

@ -1,40 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui
import androidx.recyclerview.widget.DiffUtil
import org.oxycblt.auxio.music.Item
/**
* A re-usable diff callback for all [Item] implementations. **Use this instead of creating a
* DiffCallback for each adapter.**
* @author OxygenCobalt
*/
class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
// Prevent ID collisions from occurring between datatypes.
if (oldItem.javaClass != newItem.javaClass) return false
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
// FIXME: Not correct, use item displays
return oldItem.hashCode() == newItem.hashCode()
}
}

View file

@ -38,8 +38,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
clipToPadding = false
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
setHasFixedSize(true)
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(
initialPadding.left,
initialPadding.top,

View file

@ -0,0 +1,194 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
/**
* An adapter enabling both asynchronous list updates and synchronous list updates.
*
* DiffUtil is a joke. The animations are chaotic and gaudy, it does not preserve the scroll
* position of the RecyclerView, it refuses to play along with item movements, and the speed gains
* are minimal. We would rather want to use the slower yet more reliable notifyX in nearly all
* cases, however DiffUtil does have some use in places such as search, so we still want the ability
* to use a differ while also having access to the basic adapter primitives as well. This class
* achieves it through some terrible reflection magic, and is more or less the base for all adapters
* in the app.
*
* TODO: Delegate data management to the internal adapters so that we can isolate the horrible hacks
* to the specific adapters that use need them.
*/
abstract class HybridAdapter<T, VH : RecyclerView.ViewHolder>(
diffCallback: DiffUtil.ItemCallback<T>
) : RecyclerView.Adapter<VH>() {
protected var mCurrentList = mutableListOf<T>()
val currentList: List<T>
get() = mCurrentList
// Probably okay to leak this here since it's just a callback.
@Suppress("LeakingThis") private val differ = AsyncListDiffer(this, diffCallback)
protected fun getItem(position: Int): T = mCurrentList[position]
override fun getItemCount(): Int = mCurrentList.size
fun submitList(newData: List<T>, onDone: () -> Unit = {}) {
if (newData != mCurrentList) {
mCurrentList = newData.toMutableList()
differ.submitList(newData, onDone)
}
}
@Suppress("NotifyDatasetChanged")
fun submitListHard(newList: List<T>) {
if (newList != mCurrentList) {
mCurrentList = newList.toMutableList()
differ.rewriteListUnsafe(mCurrentList)
notifyDataSetChanged()
}
}
fun moveItems(from: Int, to: Int) {
mCurrentList.add(to, mCurrentList.removeAt(from))
differ.rewriteListUnsafe(mCurrentList)
notifyItemMoved(from, to)
}
fun removeItem(at: Int) {
mCurrentList.removeAt(at)
differ.rewriteListUnsafe(mCurrentList)
notifyItemRemoved(at)
}
/**
* Rewrites the AsyncListDiffer's internal list, cancelling any diffs that are currently in
* progress. I cannot describe in words how dangerous this is, but it's also the only thing I
* can do to marry the adapter primitives with DiffUtil.
*/
private fun <T> AsyncListDiffer<T>.rewriteListUnsafe(newList: List<T>) {
differMaxGenerationsField.set(this, (differMaxGenerationsField.get(this) as Int).inc())
differListField.set(this, newList.toMutableList())
differImmutableListField.set(this, newList)
}
companion object {
private val differListField =
AsyncListDiffer::class.java.getDeclaredField("mList").apply { isAccessible = true }
private val differImmutableListField =
AsyncListDiffer::class.java.getDeclaredField("mReadOnlyList").apply {
isAccessible = true
}
private val differMaxGenerationsField =
AsyncListDiffer::class.java.getDeclaredField("mMaxScheduledGeneration").apply {
isAccessible = true
}
}
}
abstract class MonoAdapter<T, L, VH : BindingViewHolder<T, L>>(
private val listener: L,
diffCallback: DiffUtil.ItemCallback<T>
) : HybridAdapter<T, VH>(diffCallback) {
protected abstract val creator: BindingViewHolder.Creator<VH>
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
creator.create(parent.context)
override fun onBindViewHolder(viewHolder: VH, position: Int) {
viewHolder.bind(getItem(position), listener)
}
}
abstract class MultiAdapter<L>(private val listener: L, diffCallback: DiffUtil.ItemCallback<Item>) :
HybridAdapter<Item, RecyclerView.ViewHolder>(diffCallback) {
abstract fun getCreatorFromItem(
item: Item
): BindingViewHolder.Creator<out RecyclerView.ViewHolder>?
abstract fun getCreatorFromViewType(
viewType: Int
): BindingViewHolder.Creator<out RecyclerView.ViewHolder>?
abstract fun onBind(viewHolder: RecyclerView.ViewHolder, item: Item, listener: L)
override fun getItemViewType(position: Int) =
requireNotNull(getCreatorFromItem(getItem(position))) {
"Unable to get view type for item ${getItem(position)}"
}
.viewType
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
requireNotNull(getCreatorFromViewType(viewType)) {
"Unable to create viewholder for view type $viewType"
}
.create(parent.context)
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
onBind(holder, getItem(position), listener)
}
}
/** The base for all items in Auxio. */
abstract class Item {
/** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */
abstract val id: Long
}
/** A data object used solely for the "Header" UI element. */
data class Header(
override val id: Long,
/** The string resource used for the header. */
@StringRes val string: Int
) : Item()
abstract class ItemDiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
if (oldItem.javaClass != newItem.javaClass) return false
return oldItem.id == newItem.id
}
}
interface ItemClickListener {
fun onItemClick(item: Item)
}
interface MenuItemListener : ItemClickListener {
fun onOpenMenu(item: Item, anchor: View)
}
abstract class BindingViewHolder<T, L>(root: View) : RecyclerView.ViewHolder(root) {
abstract fun bind(item: T, listener: L)
init {
// Force the layout to *actually* be the screen width
root.layoutParams =
RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
}
interface Creator<VH : RecyclerView.ViewHolder> {
val viewType: Int
fun create(context: Context): VH
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2021 Auxio Project
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,99 +18,54 @@
package org.oxycblt.auxio.ui
import android.content.Context
import android.view.View
import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.coil.bindArtistImage
import org.oxycblt.auxio.coil.bindGenreImage
import org.oxycblt.auxio.databinding.ItemActionHeaderBinding
import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe
/**
* A [RecyclerView.ViewHolder] that streamlines a lot of the common things across all viewholders.
* @param T The datatype, inheriting [Item] for this ViewHolder.
* @param binding Basic [ViewDataBinding] required to set up click listeners & sizing.
* @param doOnClick (Optional) Function that calls on a click.
* @param doOnLongClick (Optional) Functions that calls on a long-click.
* @author OxygenCobalt
*/
abstract class BaseViewHolder<T : Item>(
private val binding: ViewBinding,
private val doOnClick: ((data: T) -> Unit)? = null,
private val doOnLongClick: ((view: View, data: T) -> Unit)? = null
) : RecyclerView.ViewHolder(binding.root) {
init {
// Force the layout to *actually* be the screen width
binding.root.layoutParams =
RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
}
/**
* Bind the viewholder with whatever [Item] instance that has been specified. Will call [onBind]
* on the inheriting ViewHolder.
* @param data Data that the viewholder should be bound with
*/
fun bind(data: T) {
doOnClick?.let { onClick -> binding.root.setOnClickListener { onClick(data) } }
doOnLongClick?.let { onLongClick ->
binding.root.setOnLongClickListener { view ->
onLongClick(view, data)
class SongViewHolder private constructor(private val binding: ItemSongBinding) :
BindingViewHolder<Song, MenuItemListener>(binding.root) {
override fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = item.resolvedName
binding.songInfo.textSafe = item.resolvedArtistName
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
onBind(data)
}
/**
* Function that performs binding operations unique to the inheriting viewholder. Add any
* specialized code to an override of this instead of [BaseViewHolder] itself.
*/
protected abstract fun onBind(data: T)
}
/** The Shared ViewHolder for a [Song]. Instantiation should be done with [from]. */
class SongViewHolder
private constructor(
private val binding: ItemSongBinding,
doOnClick: (data: Song) -> Unit,
doOnLongClick: (view: View, data: Song) -> Unit
) : BaseViewHolder<Song>(binding, doOnClick, doOnLongClick) {
override fun onBind(data: Song) {
binding.songAlbumCover.bindAlbumCover(data)
binding.songName.textSafe = data.resolvedName
binding.songInfo.textSafe = data.resolvedArtistName
}
companion object {
/** Create an instance of [SongViewHolder] */
fun from(
context: Context,
doOnClick: (data: Song) -> Unit,
doOnLongClick: (view: View, data: Song) -> Unit
): SongViewHolder {
return SongViewHolder(
ItemSongBinding.inflate(context.inflater), doOnClick, doOnLongClick)
}
val CREATOR =
object : Creator<SongViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_SONG
override fun create(context: Context) =
SongViewHolder(ItemSongBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.resolvedArtistName == oldItem.resolvedArtistName
}
}
}
@ -118,122 +73,142 @@ private constructor(
class AlbumViewHolder
private constructor(
private val binding: ItemParentBinding,
doOnClick: (data: Album) -> Unit,
doOnLongClick: (view: View, data: Album) -> Unit
) : BaseViewHolder<Album>(binding, doOnClick, doOnLongClick) {
) : BindingViewHolder<Album, MenuItemListener>(binding.root) {
override fun onBind(data: Album) {
binding.parentImage.bindAlbumCover(data)
binding.parentName.textSafe = data.resolvedName
binding.parentInfo.textSafe = data.resolvedArtistName
override fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bindAlbumCover(item)
binding.parentName.textSafe = item.resolvedName
binding.parentInfo.textSafe = item.resolvedArtistName
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
}
companion object {
/** Create an instance of [AlbumViewHolder] */
fun from(
context: Context,
doOnClick: (data: Album) -> Unit,
doOnLongClick: (view: View, data: Album) -> Unit
): AlbumViewHolder {
return AlbumViewHolder(
ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick)
}
val CREATOR =
object : Creator<AlbumViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_ALBUM
override fun create(context: Context) =
AlbumViewHolder(ItemParentBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Album>() {
override fun areItemsTheSame(oldItem: Album, newItem: Album) =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.resolvedArtistName == newItem.resolvedArtistName
}
}
}
/** The Shared ViewHolder for a [Artist]. Instantiation should be done with [from]. */
class ArtistViewHolder
private constructor(
private val binding: ItemParentBinding,
doOnClick: (Artist) -> Unit,
doOnLongClick: (view: View, data: Artist) -> Unit
) : BaseViewHolder<Artist>(binding, doOnClick, doOnLongClick) {
/** The Shared ViewHolder for a [Artist]. */
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
BindingViewHolder<Artist, MenuItemListener>(binding.root) {
override fun onBind(data: Artist) {
binding.parentImage.bindArtistImage(data)
binding.parentName.textSafe = data.resolvedName
override fun bind(item: Artist, listener: MenuItemListener) {
binding.parentImage.bindArtistImage(item)
binding.parentName.textSafe = item.resolvedName
binding.parentInfo.textSafe =
binding.context.getString(
R.string.fmt_two,
binding.context.getPluralSafe(R.plurals.fmt_album_count, data.albums.size),
binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size))
binding.context.getPluralSafe(R.plurals.fmt_album_count, item.albums.size),
binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size))
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
}
companion object {
/** Create an instance of [ArtistViewHolder] */
fun from(
context: Context,
doOnClick: (Artist) -> Unit,
doOnLongClick: (view: View, data: Artist) -> Unit
): ArtistViewHolder {
return ArtistViewHolder(
ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick)
}
val CREATOR =
object : Creator<ArtistViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_ARTIST
override fun create(context: Context) =
ArtistViewHolder(ItemParentBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Artist>() {
override fun areItemsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.albums.size == newItem.albums.size &&
newItem.songs.size == newItem.songs.size
}
}
}
/** The Shared ViewHolder for a [Genre]. Instantiation should be done with [from]. */
/** The Shared ViewHolder for a [Genre]. */
class GenreViewHolder
private constructor(
private val binding: ItemParentBinding,
doOnClick: (Genre) -> Unit,
doOnLongClick: (view: View, data: Genre) -> Unit
) : BaseViewHolder<Genre>(binding, doOnClick, doOnLongClick) {
) : BindingViewHolder<Genre, MenuItemListener>(binding.root) {
override fun onBind(data: Genre) {
binding.parentImage.bindGenreImage(data)
binding.parentName.textSafe = data.resolvedName
override fun bind(item: Genre, listener: MenuItemListener) {
binding.parentImage.bindGenreImage(item)
binding.parentName.textSafe = item.resolvedName
binding.parentInfo.textSafe =
binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size)
binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size)
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
}
companion object {
/** Create an instance of [GenreViewHolder] */
fun from(
context: Context,
doOnClick: (Genre) -> Unit,
doOnLongClick: (view: View, data: Genre) -> Unit
): GenreViewHolder {
return GenreViewHolder(
ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick)
}
val CREATOR =
object : Creator<GenreViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_GENRE
override fun create(context: Context) =
GenreViewHolder(ItemParentBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Genre>() {
override fun areItemsTheSame(oldItem: Genre, newItem: Genre): Boolean =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.songs.size == newItem.songs.size
}
}
}
/** The Shared ViewHolder for a [Header]. Instantiation should be done with [from] */
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
BaseViewHolder<Header>(binding) {
class NewHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
BindingViewHolder<Header, Unit>(binding.root) {
override fun onBind(data: Header) {
binding.title.textSafe = binding.context.getString(data.string)
override fun bind(item: Header, listener: Unit) {
binding.title.textSafe = binding.context.getString(item.string)
}
companion object {
/** Create an instance of [HeaderViewHolder] */
fun from(context: Context): HeaderViewHolder {
return HeaderViewHolder(ItemHeaderBinding.inflate(context.inflater))
}
}
}
/** The Shared ViewHolder for an [ActionHeader]. Instantiation should be done with [from] */
class ActionHeaderViewHolder private constructor(private val binding: ItemActionHeaderBinding) :
BaseViewHolder<ActionHeader>(binding) {
override fun onBind(data: ActionHeader) {
binding.headerTitle.textSafe = binding.context.getString(data.string)
binding.headerButton.apply {
setImageResource(data.icon)
contentDescription = context.getString(data.desc)
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener(data.onClick)
}
}
companion object {
/** Create an instance of [ActionHeaderViewHolder] */
fun from(context: Context): ActionHeaderViewHolder {
return ActionHeaderViewHolder(ItemActionHeaderBinding.inflate(context.inflater))
}
val CREATOR =
object : Creator<NewHeaderViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_HEADER
override fun create(context: Context) =
NewHeaderViewHolder(ItemHeaderBinding.inflate(context.inflater))
}
val DIFFER =
object : ItemDiffCallback<Header>() {
override fun areItemsTheSame(oldItem: Header, newItem: Header): Boolean =
oldItem.string == newItem.string
}
}
}

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.util
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.os.Looper
import androidx.fragment.app.Fragment
/**
* Shortcut for querying all items in a database and running [block] with the cursor returned. Will
@ -34,3 +35,9 @@ fun assertBackgroundThread() {
"This operation must be ran on a background thread"
}
}
fun Fragment.requireAttached() {
if (isDetached) {
error("Fragment is detached from activity")
}
}

View file

@ -123,6 +123,13 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
}
}
@Suppress("UNCHECKED_CAST")
fun RecyclerView.getViewHolderAt(pos: Int): RecyclerView.ViewHolder? {
return layoutManager?.run {
findViewByPosition(pos)?.let { child -> getChildViewHolder(child) }
}
}
/** Returns whether a recyclerview can scroll. */
fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height

View file

@ -26,11 +26,12 @@
android:layout_height="wrap_content"
android:gravity="center"
android:maxLines="1"
android:minWidth="@dimen/size_track_number"
android:minWidth="@dimen/size_btn_small"
android:minHeight="@dimen/size_btn_small"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
android:textColor="@color/sel_accented_secondary"
android:textSize="@dimen/text_size_ext_title_mid_large"
android:textSize="@dimen/text_size_track_number"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_name"
app:layout_constraintHorizontal_bias="0.5"

View file

@ -29,8 +29,8 @@
app:layout_constraintBottom_toTopOf="@id/header_divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:src="@drawable/ic_sort" />
android:contentDescription="@string/lbl_sort"
android:src="@drawable/ic_sort" />
<com.google.android.material.divider.MaterialDivider
android:id="@+id/header_divider"

View file

@ -26,6 +26,7 @@
<dimen name="text_size_ext_label_larger">16sp</dimen>
<dimen name="text_size_ext_title_mid_large">18sp</dimen>
<dimen name="text_size_track_number">22sp</dimen>
<!-- Misc -->
<dimen name="elevation_small">2dp</dimen>