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 ] - Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese ]
- Switched to spotless and ktfmt instead of ktlint - Switched to spotless and ktfmt instead of ktlint
- Migrated constants to centralized table - Migrated constants to centralized table
- Removed databinding [Greatly reduces compile times]
- A bunch of internal view implementation improvements - A bunch of internal view implementation improvements
## v2.2.2 ## 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.coil.MusicKeyer
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
/** TODO: Rework RecyclerView management and item dragging */ /** TODO: Rework null-safety/usage of requireNotNull */
@Suppress("UNUSED") @Suppress("UNUSED")
class AuxioApp : Application(), ImageLoaderFactory { class AuxioApp : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {

View file

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

View file

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

View file

@ -18,21 +18,24 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode 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.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -40,40 +43,27 @@ import org.oxycblt.auxio.util.logW
* The [DetailFragment] for an artist. * The [DetailFragment] for an artist.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ArtistDetailFragment : DetailFragment() { class ArtistDetailFragment : DetailFragment(), DetailItemListener {
private val args: ArtistDetailFragmentArgs by navArgs() private val args: ArtistDetailFragmentArgs by navArgs()
private val detailAdapter = ArtistDetailAdapter(this)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setArtistId(args.artistId) 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!!) setupToolbar(detailModel.currentArtist.value!!)
setupRecycler(detailAdapter) { pos -> requireBinding().detailRecycler.apply {
adapter = detailAdapter
applySpans { pos ->
// If the item is an ActionHeader we need to also make the item full-width // If the item is an ActionHeader we need to also make the item full-width
val item = detailAdapter.currentList[pos] val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Artist item is Header || item is SortHeader || item is Artist
}
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
detailModel.artistData.observe(viewLifecycleOwner, detailAdapter::submitList) detailModel.artistData.observe(viewLifecycleOwner) { list ->
detailAdapter.submitList(list)
detailModel.showMenu.observe(viewLifecycleOwner) { config ->
if (config != null) {
showMenu(config) { id -> id != R.id.option_sort_artist }
}
} }
detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation) 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?) { private fun handleNavigation(item: Music?) {
val binding = requireBinding() val binding = requireBinding()

View file

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

View file

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

View file

@ -18,20 +18,23 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailItemListener
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode 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.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -39,37 +42,54 @@ import org.oxycblt.auxio.util.logW
* The [DetailFragment] for a genre. * The [DetailFragment] for a genre.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class GenreDetailFragment : DetailFragment() { class GenreDetailFragment : DetailFragment(), DetailItemListener {
private val args: GenreDetailFragmentArgs by navArgs() private val args: GenreDetailFragmentArgs by navArgs()
private val detailAdapter = GenreDetailAdapter(this)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setGenreId(args.genreId) 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!!) setupToolbar(detailModel.currentGenre.value!!)
setupRecycler(detailAdapter) { pos -> binding.detailRecycler.apply {
adapter = detailAdapter
applySpans { pos ->
val item = detailAdapter.currentList[pos] val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Genre item is Header || item is SortHeader || item is Genre
}
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
detailModel.genreData.observe(viewLifecycleOwner, detailAdapter::submitList) detailModel.genreData.observe(viewLifecycleOwner) { list -> detailAdapter.submitList(list) }
detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation) detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation)
playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) } playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) }
}
detailModel.showMenu.observe(viewLifecycleOwner) { config -> override fun onItemClick(item: Item) {
if (config != null) { when (item) {
showMenu(config) 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?) { private fun handleNavigation(item: Music?) {

View file

@ -17,82 +17,71 @@
package org.oxycblt.auxio.detail.recycler package org.oxycblt.auxio.detail.recycler
import android.view.View import android.content.Context
import android.view.ViewGroup
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding 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.Album
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toDuration import org.oxycblt.auxio.music.toDuration
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.ActionHeaderViewHolder import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.BaseViewHolder import org.oxycblt.auxio.ui.ItemDiffCallback
import org.oxycblt.auxio.ui.DiffCallback import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe 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 * @author OxygenCobalt
*/ */
class AlbumDetailAdapter( class AlbumDetailAdapter(listener: AlbumDetailItemListener) :
private val playbackModel: PlaybackViewModel, DetailAdapter<AlbumDetailItemListener>(listener, DIFFER) {
private val detailModel: DetailViewModel, private var highlightedSong: Song? = null
private val doOnClick: (data: Song) -> Unit, private var highlightedViewHolder: Highlightable? = null
private val doOnLongClick: (view: View, data: Song) -> Unit
) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback()) {
private var currentSong: Song? = null
private var currentHolder: Highlightable? = null
override fun getItemViewType(position: Int): Int { override fun getCreatorFromItem(item: Item) =
return when (getItem(position)) { super.getCreatorFromItem(item)
is Album -> IntegerTable.ITEM_TYPE_ALBUM_DETAIL ?: when (item) {
is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER is Album -> AlbumDetailViewHolder.CREATOR
is Song -> IntegerTable.ITEM_TYPE_ALBUM_SONG is Song -> AlbumSongViewHolder.CREATOR
else -> -1 else -> null
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun getCreatorFromViewType(viewType: Int) =
return when (viewType) { super.getCreatorFromViewType(viewType)
IntegerTable.ITEM_TYPE_ALBUM_DETAIL -> ?: when (viewType) {
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) AlbumDetailViewHolder.CREATOR.viewType -> AlbumDetailViewHolder.CREATOR
IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context) AlbumSongViewHolder.CREATOR.viewType -> AlbumSongViewHolder.CREATOR
IntegerTable.ITEM_TYPE_ALBUM_SONG -> else -> null
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
else -> error("Invalid ViewHolder item type $viewType")
}
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBind(
val item = getItem(position) viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: AlbumDetailItemListener
) {
super.onBind(viewHolder, item, listener)
when (item) { when (item) {
is Album -> (holder as AlbumDetailViewHolder).bind(item) is Album -> (viewHolder as AlbumDetailViewHolder).bind(item, listener)
is Song -> (holder as AlbumSongViewHolder).bind(item) is Song -> (viewHolder as AlbumSongViewHolder).bind(item, listener)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) }
else -> {}
} }
if (holder is Highlightable) { override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) {
if (item.id == currentSong?.id) { if (item is Song && item.id == highlightedSong?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted // Reset the last ViewHolder before assigning the new, correct one to be highlighted
currentHolder?.setHighlighted(false) highlightedViewHolder?.setHighlighted(false)
currentHolder = holder highlightedViewHolder = viewHolder
holder.setHighlighted(true) viewHolder.setHighlighted(true)
} else { } else {
holder.setHighlighted(false) viewHolder.setHighlighted(false)
}
} }
} }
@ -101,68 +90,88 @@ class AlbumDetailAdapter(
* @param recycler The recyclerview the highlighting should act on. * @param recycler The recyclerview the highlighting should act on.
*/ */
fun highlightSong(song: Song?, recycler: RecyclerView) { fun highlightSong(song: Song?, recycler: RecyclerView) {
if (song == currentSong) return // Already highlighting this ViewHolder if (song == highlightedSong) return
highlightedSong = song
highlightedViewHolder?.setHighlighted(false)
highlightedViewHolder = highlightItem(song, recycler)
}
// Clear the current ViewHolder since it's invalid companion object {
currentHolder?.setHighlighted(false) private val DIFFER =
currentHolder = null object : ItemDiffCallback<Item>() {
currentSong = song override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
if (song != null) { oldItem is Album && newItem is Album ->
// Use existing data instead of having to re-sort it. AlbumDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song } oldItem is SortHeader && newItem is SortHeader ->
SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
// Check if the ViewHolder for this song is visible, if it is then highlight it. oldItem is Song && newItem is Song ->
// If the ViewHolder is not visible, then the adapter should take care of it if AlbumSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
// it does become visible. else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem)
recycler.layoutManager?.findViewByPosition(pos)?.let { child -> }
recycler.getChildViewHolder(child)?.let {
currentHolder = it as Highlightable
currentHolder?.setHighlighted(true)
} }
} }
} }
} }
inner class AlbumDetailViewHolder(private val binding: ItemDetailBinding) : interface AlbumDetailItemListener : DetailItemListener {
BaseViewHolder<Album>(binding) { fun onNavigateToArtist()
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 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 { binding.detailSubhead.apply {
textSafe = data.artist.resolvedName textSafe = item.resolvedArtistName
setOnClickListener { detailModel.navToItem(data.artist) } setOnClickListener { listener.onNavigateToArtist() }
} }
binding.detailInfo.apply { binding.detailInfo.apply {
text = text =
context.getString( context.getString(
R.string.fmt_three, R.string.fmt_three,
data.year?.toString() ?: context.getString(R.string.def_date), item.year?.toString() ?: context.getString(R.string.def_date),
context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size), context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size),
data.totalDuration) item.totalDuration)
} }
binding.detailPlayButton.setOnClickListener { playbackModel.playAlbum(data, false) } binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
binding.detailShuffleButton.setOnClickListener { playbackModel.playAlbum(data, true) } 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
}
} }
} }
inner class AlbumSongViewHolder( private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) :
private val binding: ItemAlbumSongBinding, BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable {
) : BaseViewHolder<Song>(binding, doOnClick, doOnLongClick), Highlightable { override fun bind(item: Song, listener: MenuItemListener) {
override fun onBind(data: Song) {
// Hide the track number view if the song does not have a track. // Hide the track number view if the song does not have a track.
if (data.track != null) { if (item.track != null) {
binding.songTrack.apply { binding.songTrack.apply {
textSafe = context.getString(R.string.fmt_number, data.track) textSafe = context.getString(R.string.fmt_number, item.track)
isInvisible = false isInvisible = false
} }
@ -176,8 +185,16 @@ class AlbumDetailAdapter(
binding.songTrackPlaceholder.isInvisible = false binding.songTrackPlaceholder.isInvisible = false
} }
binding.songName.textSafe = data.resolvedName binding.songName.textSafe = item.resolvedName
binding.songDuration.textSafe = data.seconds.toDuration(false) 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) { override fun setHighlighted(isHighlighted: Boolean) {
@ -185,5 +202,22 @@ class AlbumDetailAdapter(
binding.songTrack.isActivated = isHighlighted binding.songTrack.isActivated = isHighlighted
binding.songTrackPlaceholder.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 package org.oxycblt.auxio.detail.recycler
import android.view.View import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R 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.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist 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.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ArtistViewHolder
import org.oxycblt.auxio.ui.ActionHeaderViewHolder import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.DiffCallback import org.oxycblt.auxio.ui.ItemDiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe 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 * @author OxygenCobalt
*/ */
class ArtistDetailAdapter( class ArtistDetailAdapter(listener: DetailItemListener) :
private val playbackModel: PlaybackViewModel, DetailAdapter<DetailItemListener>(listener, DIFFER) {
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()) {
private var currentAlbum: Album? = null private var currentAlbum: Album? = null
private var currentAlbumHolder: Highlightable? = null private var currentAlbumHolder: Highlightable? = null
private var currentSong: Song? = null private var currentSong: Song? = null
private var currentSongHolder: Highlightable? = null private var currentSongHolder: Highlightable? = null
override fun getItemViewType(position: Int): Int { override fun getCreatorFromItem(item: Item) =
return when (getItem(position)) { super.getCreatorFromItem(item)
is Artist -> IntegerTable.ITEM_TYPE_ARTIST_DETAIL ?: when (item) {
is Album -> IntegerTable.ITEM_TYPE_ARTIST_ALBUM is Artist -> ArtistDetailViewHolder.CREATOR
is Song -> IntegerTable.ITEM_TYPE_ARTIST_SONG is Album -> ArtistAlbumViewHolder.CREATOR
is Header -> IntegerTable.ITEM_TYPE_HEADER is Song -> ArtistSongViewHolder.CREATOR
is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER else -> null
else -> -1
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun getCreatorFromViewType(viewType: Int) =
return when (viewType) { super.getCreatorFromViewType(viewType)
IntegerTable.ITEM_TYPE_ARTIST_DETAIL -> ?: when (viewType) {
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) ArtistDetailViewHolder.CREATOR.viewType -> ArtistDetailViewHolder.CREATOR
IntegerTable.ITEM_TYPE_ARTIST_ALBUM -> ArtistAlbumViewHolder.CREATOR.viewType -> ArtistAlbumViewHolder.CREATOR
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater)) ArtistSongViewHolder.CREATOR.viewType -> ArtistSongViewHolder.CREATOR
IntegerTable.ITEM_TYPE_ARTIST_SONG -> else -> null
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) { override fun onBind(
val item = getItem(position) viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: DetailItemListener
) {
super.onBind(viewHolder, item, listener)
when (item) { when (item) {
is Artist -> (holder as ArtistDetailViewHolder).bind(item) is Artist -> (viewHolder as ArtistDetailViewHolder).bind(item, listener)
is Album -> (holder as ArtistAlbumViewHolder).bind(item) is Album -> (viewHolder as ArtistAlbumViewHolder).bind(item, listener)
is Song -> (holder as ArtistSongViewHolder).bind(item) is Song -> (viewHolder as ArtistSongViewHolder).bind(item, listener)
is Header -> (holder as HeaderViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
else -> {} else -> {}
} }
}
if (holder is Highlightable) { override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) {
// If the item corresponds to a currently playing song/album then highlight it // If the item corresponds to a currently playing song/album then highlight it
if (item.id == currentAlbum?.id && item is Album) { if (item.id == currentAlbum?.id && item is Album) {
currentAlbumHolder?.setHighlighted(false) currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = holder currentAlbumHolder = viewHolder
holder.setHighlighted(true) viewHolder.setHighlighted(true)
} else if (item.id == currentSong?.id && item is Song) { } else if (item.id == currentSong?.id && item is Song) {
currentSongHolder?.setHighlighted(false) currentSongHolder?.setHighlighted(false)
currentSongHolder = holder currentSongHolder = viewHolder
holder.setHighlighted(true) viewHolder.setHighlighted(true)
} else { } else {
holder.setHighlighted(false) viewHolder.setHighlighted(false)
}
} }
} }
@ -118,26 +104,10 @@ class ArtistDetailAdapter(
* @param recycler The recyclerview the highlighting should act on. * @param recycler The recyclerview the highlighting should act on.
*/ */
fun highlightAlbum(album: Album?, recycler: RecyclerView) { fun highlightAlbum(album: Album?, recycler: RecyclerView) {
if (album == currentAlbum) return // Already highlighting this ViewHolder if (album == currentAlbum) return
// Album is no longer valid, clear out this ViewHolder.
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = null
currentAlbum = album currentAlbum = album
currentAlbumHolder?.setHighlighted(false)
if (album != null) { currentAlbumHolder = highlightItem(album, recycler)
// 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)
}
}
}
} }
/** /**
@ -145,91 +115,144 @@ class ArtistDetailAdapter(
* @param recycler The recyclerview the highlighting should act on. * @param recycler The recyclerview the highlighting should act on.
*/ */
fun highlightSong(song: Song?, recycler: RecyclerView) { fun highlightSong(song: Song?, recycler: RecyclerView) {
if (song == currentSong) return // Already highlighting this ViewHolder if (song == currentSong) return
// Clear the current ViewHolder since it's invalid
currentSongHolder?.setHighlighted(false)
currentSongHolder = null
currentSong = song currentSong = song
currentSongHolder?.setHighlighted(false)
currentSongHolder = highlightItem(song, recycler)
}
if (song != null) { companion object {
// Use existing data instead of having to re-sort it. private val DIFFER =
// We also have to account for the album count when searching for the ViewHolder. object : ItemDiffCallback<Item>() {
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song } override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
// Check if the ViewHolder for this song is visible, if it is then highlight it. oldItem is Artist && newItem is Artist ->
// If the ViewHolder is not visible, then the adapter should take care of it if ArtistDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
// it does become visible. oldItem is Album && newItem is Album ->
recycler.layoutManager?.findViewByPosition(pos)?.let { child -> ArtistAlbumViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
recycler.getChildViewHolder(child)?.let { oldItem is Song && newItem is Song ->
currentSongHolder = it as Highlightable ArtistSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
currentSongHolder?.setHighlighted(true) else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem)
}
} }
} }
} }
} }
inner class ArtistDetailViewHolder(private val binding: ItemDetailBinding) : private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
BaseViewHolder<Artist>(binding) { BindingViewHolder<Artist, DetailItemListener>(binding.root) {
override fun onBind(data: Artist) { override fun bind(item: Artist, listener: DetailItemListener) {
val context = binding.root.context binding.detailCover.bindArtistImage(item)
binding.detailName.textSafe = item.resolvedName
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 // Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre. // the most "Prominent" genre.
binding.detailSubhead.textSafe = binding.detailSubhead.textSafe =
data.songs item.songs.groupBy { it.genre.resolvedName }.entries.maxByOrNull { it.value.size }?.key
.groupBy { it.genre.resolvedName } ?: binding.context.getString(R.string.def_genre)
.entries
.maxByOrNull { it.value.size }
?.key
?: context.getString(R.string.def_genre)
binding.detailInfo.textSafe = binding.detailInfo.textSafe =
binding.context.getString( binding.context.getString(
R.string.fmt_two, R.string.fmt_two,
binding.context.getPluralSafe(R.plurals.fmt_album_count, data.albums.size), binding.context.getPluralSafe(R.plurals.fmt_album_count, item.albums.size),
binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size)) binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size))
binding.detailPlayButton.setOnClickListener { playbackModel.playArtist(data, false) } binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
binding.detailShuffleButton.setOnClickListener { playbackModel.playArtist(data, true) } 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
} }
} }
inner class ArtistAlbumViewHolder( private class ArtistAlbumViewHolder
private constructor(
private val binding: ItemParentBinding, private val binding: ItemParentBinding,
) : BaseViewHolder<Album>(binding, doOnClick, doOnLongClick), Highlightable { ) : BindingViewHolder<Album, MenuItemListener>(binding.root), Highlightable {
override fun onBind(data: Album) { override fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bindAlbumCover(data) binding.parentImage.bindAlbumCover(item)
binding.parentName.textSafe = data.resolvedName binding.parentName.textSafe = item.resolvedName
binding.parentInfo.textSafe = binding.context.getString(R.string.fmt_number, data.year) 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) { override fun setHighlighted(isHighlighted: Boolean) {
binding.parentName.isActivated = isHighlighted 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))
} }
inner class ArtistSongViewHolder( 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, private val binding: ItemSongBinding,
) : BaseViewHolder<Song>(binding, doOnSongClick, doOnLongClick), Highlightable { ) : BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable {
override fun onBind(data: Song) { override fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bindAlbumCover(data) binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = data.resolvedName binding.songName.textSafe = item.resolvedName
binding.songInfo.textSafe = data.resolvedAlbumName binding.songInfo.textSafe = item.resolvedAlbumName
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
} }
override fun setHighlighted(isHighlighted: Boolean) { override fun setHighlighted(isHighlighted: Boolean) {
binding.songName.isActivated = isHighlighted 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 package org.oxycblt.auxio.detail.recycler
import android.view.View import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R 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.coil.bindGenreImage
import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.ActionHeaderViewHolder import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.BaseViewHolder import org.oxycblt.auxio.ui.ItemDiffCallback
import org.oxycblt.auxio.ui.DiffCallback import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe 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 * @author OxygenCobalt
*/ */
class GenreDetailAdapter( class GenreDetailAdapter(listener: DetailItemListener) :
private val playbackModel: PlaybackViewModel, DetailAdapter<DetailItemListener>(listener, DIFFER) {
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 currentSong: Song? = null
private var currentHolder: Highlightable? = null private var currentHolder: Highlightable? = null
override fun getItemViewType(position: Int): Int { override fun getCreatorFromItem(item: Item) =
return when (getItem(position)) { super.getCreatorFromItem(item)
is Genre -> IntegerTable.ITEM_TYPE_GENRE_DETAIL ?: when (item) {
is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER is Genre -> GenreDetailViewHolder.CREATOR
is Song -> IntegerTable.ITEM_TYPE_GENRE_SONG is Song -> GenreSongViewHolder.CREATOR
else -> -1 else -> null
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun getCreatorFromViewType(viewType: Int) =
return when (viewType) { super.getCreatorFromViewType(viewType)
IntegerTable.ITEM_TYPE_GENRE_DETAIL -> ?: when (viewType) {
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) GenreDetailViewHolder.CREATOR.viewType -> GenreDetailViewHolder.CREATOR
IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context) GenreSongViewHolder.CREATOR.viewType -> GenreSongViewHolder.CREATOR
IntegerTable.ITEM_TYPE_GENRE_SONG -> else -> null
GenreSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
else -> error("Bad ViewHolder item type $viewType")
}
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBind(
val item = getItem(position) viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: DetailItemListener
) {
super.onBind(viewHolder, item, listener)
when (item) { when (item) {
is Genre -> (holder as GenreDetailViewHolder).bind(item) is Genre -> (viewHolder as GenreDetailViewHolder).bind(item, listener)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) is Song -> (viewHolder as GenreSongViewHolder).bind(item, listener)
is Song -> (holder as GenreSongViewHolder).bind(item)
else -> {} else -> {}
} }
}
if (holder is Highlightable) { 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) { if (item.id == currentSong?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted // Reset the last ViewHolder before assigning the new, correct one to be highlighted
currentHolder?.setHighlighted(false) currentHolder?.setHighlighted(false)
currentHolder = holder currentHolder = viewHolder
holder.setHighlighted(true) viewHolder.setHighlighted(true)
} else { } else {
holder.setHighlighted(false) viewHolder.setHighlighted(false)
}
} }
} }
@ -99,64 +92,89 @@ class GenreDetailAdapter(
* @param recycler The recyclerview the highlighting should act on. * @param recycler The recyclerview the highlighting should act on.
*/ */
fun highlightSong(song: Song?, recycler: RecyclerView) { fun highlightSong(song: Song?, recycler: RecyclerView) {
if (song == currentSong) return // Already highlighting this ViewHolder if (song == currentSong) return
// Clear the current ViewHolder since it's invalid
currentHolder?.setHighlighted(false)
currentHolder = null
currentSong = song currentSong = song
currentHolder?.setHighlighted(false)
currentHolder = highlightItem(song, recycler)
}
if (song != null) { companion object {
// Use existing data instead of having to re-sort it. val DIFFER =
val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song } object : ItemDiffCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
// Check if the ViewHolder for this song is visible, if it is then highlight it. return when {
// If the ViewHolder is not visible, then the adapter should take care of it if oldItem is Genre && newItem is Genre ->
// it does become visible. GenreDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
recycler.layoutManager?.findViewByPosition(pos)?.let { child -> oldItem is Song && newItem is Song ->
recycler.getChildViewHolder(child)?.let { GenreSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
currentHolder = it as Highlightable else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem)
currentHolder?.setHighlighted(true) }
} }
} }
} }
} }
inner class GenreDetailViewHolder(private val binding: ItemDetailBinding) : private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
BaseViewHolder<Genre>(binding) { BindingViewHolder<Genre, DetailItemListener>(binding.root) {
override fun onBind(data: Genre) { override fun bind(item: Genre, listener: DetailItemListener) {
val context = binding.root.context binding.detailCover.bindGenreImage(item)
binding.detailName.textSafe = item.resolvedName
binding.detailCover.apply {
bindGenreImage(data)
contentDescription = context.getString(R.string.desc_genre_image, data.resolvedName)
}
binding.detailName.textSafe = data.resolvedName
binding.detailSubhead.textSafe = binding.detailSubhead.textSafe =
binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size) binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size)
binding.detailInfo.textSafe = data.totalDuration binding.detailInfo.textSafe = item.totalDuration
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
binding.detailPlayButton.setOnClickListener { playbackModel.playGenre(data, false) } companion object {
val CREATOR =
object : Creator<GenreDetailViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_GENRE_DETAIL
binding.detailShuffleButton.setOnClickListener { playbackModel.playGenre(data, true) } 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
}
} }
} }
/** The Shared ViewHolder for a [Song]. Instantiation should be done with [from]. */ class GenreSongViewHolder private constructor(private val binding: ItemSongBinding) :
inner class GenreSongViewHolder BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable {
constructor( override fun bind(item: Song, listener: MenuItemListener) {
private val binding: ItemSongBinding, binding.songAlbumCover.bindAlbumCover(item)
) : BaseViewHolder<Song>(binding, doOnClick, doOnLongClick), Highlightable { binding.songName.textSafe = item.resolvedName
binding.songInfo.textSafe = item.resolvedArtistName
override fun onBind(data: Song) { binding.root.apply {
binding.songAlbumCover.bindAlbumCover(data) setOnClickListener { listener.onItemClick(item) }
binding.songName.textSafe = data.resolvedName setOnLongClickListener { view ->
binding.songInfo.textSafe = data.resolvedArtistName listener.onOpenMenu(item, view)
true
}
}
} }
override fun setHighlighted(isHighlighted: Boolean) { override fun setHighlighted(isHighlighted: Boolean) {
binding.songName.isActivated = isHighlighted 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) removeCallbacks(hideThumbRunnable)
showScrollbar() showScrollbar()
showPopup() showPopup()
onDragListener?.onFastScrollStart()
} else { } else {
postAutoHideScrollbar() postAutoHideScrollbar()
hidePopup() hidePopup()
onDragListener?.onFastScrollStop()
} }
onDragListener?.invoke(value)
} }
private val tRect = Rect() 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 */ /** 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 * A listener for when a drag event occurs. The value will be true if a drag has begun, and
* false if a drag ended. * false if a drag ended.
*/ */
var onDragListener: ((Boolean) -> Unit)? = null var onDragListener: OnFastScrollListener? = null
init { init {
overlay.add(thumbView) overlay.add(thumbView)
@ -186,8 +195,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- RECYCLERVIEW EVENT MANAGEMENT --- // --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() { 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() updateScrollbarState()
thumbView.layoutDirection = layoutDirection thumbView.layoutDirection = layoutDirection
@ -207,13 +214,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
val firstPos = firstAdapterPos val firstPos = firstAdapterPos
val popupText = val popupText =
if (firstPos != NO_POSITION) { if (firstPos != NO_POSITION) {
popupProvider?.invoke(firstPos)?.ifEmpty { null } popupProvider?.getPopup(firstPos)?.ifEmpty { null }
} else { } else {
null null
} }
// Lay out the popup view
popupView.isInvisible = popupText == null popupView.isInvisible = popupText == null
if (popupText != null) { if (popupText != null) {
@ -370,6 +375,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun scrollTo(offset: Int) { private fun scrollTo(offset: Int) {
if (childCount == 0) {
return
}
stopScroll() stopScroll()
val trueOffset = offset - paddingTop val trueOffset = offset - paddingTop

View file

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

View file

@ -17,52 +17,43 @@
package org.oxycblt.auxio.home.list package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.ui.GenreViewHolder 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.newMenu
import org.oxycblt.auxio.ui.sliceArticle import org.oxycblt.auxio.ui.sliceArticle
import org.oxycblt.auxio.util.context
/** /**
* A [HomeListFragment] for showing a list of [Genre]s. * A [HomeListFragment] for showing a list of [Genre]s.
* @author * @author
*/ */
class GenreListFragment : HomeListFragment() { class GenreListFragment : HomeListFragment<Genre>() {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { override val recyclerId = R.id.home_genre_list
val homeAdapter = override val homeAdapter = GenreAdapter(this)
GenreAdapter( override val homeData: LiveData<List<Genre>>
doOnClick = { Genre -> get() = homeModel.genres
findNavController().navigate(HomeFragmentDirections.actionShowGenre(Genre.id))
},
::newMenu)
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 override fun onOpenMenu(item: Item, anchor: View) {
get() = { idx -> newMenu(anchor, item)
homeModel.genres.value!![idx].resolvedName.sliceArticle().first().uppercase()
} }
class GenreAdapter( class GenreAdapter(listener: MenuItemListener) :
private val doOnClick: (data: Genre) -> Unit, MonoAdapter<Genre, MenuItemListener, GenreViewHolder>(listener, GenreViewHolder.DIFFER) {
private val doOnLongClick: (view: View, data: Genre) -> Unit, override val creator = GenreViewHolder.CREATOR
) : 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])
}
} }
} }

View file

@ -17,17 +17,19 @@
package org.oxycblt.auxio.home.list package org.oxycblt.auxio.home.list
import android.annotation.SuppressLint import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.annotation.IdRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel 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.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.ui.ViewBindingFragment
import org.oxycblt.auxio.util.applySpans 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. * A Base [Fragment] implementing the base features shared across all list fragments in the home UI.
* @author OxygenCobalt * @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. */ /** 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 homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel 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) = override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater) FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
binding.homeRecycler.apply {
id = recyclerId
adapter = homeAdapter
applySpans()
}
binding.homeRecycler.popupProvider = this
binding.homeRecycler.onDragListener = this
homeData.observe(viewLifecycleOwner) { list ->
homeAdapter.submitListHard(list.toMutableList())
}
}
override fun onDestroyBinding(binding: FragmentHomeListBinding) { override fun onDestroyBinding(binding: FragmentHomeListBinding) {
homeModel.updateFastScrolling(false) homeModel.updateFastScrolling(false)
binding.homeRecycler.apply {
adapter = null
popupProvider = null
onDragListener = null
}
} }
abstract class HomeAdapter<T : Item, VH : RecyclerView.ViewHolder> : override fun onFastScrollStart() {
RecyclerView.Adapter<VH>() { homeModel.updateFastScrolling(true)
protected var data = listOf<T>() }
@SuppressLint("NotifyDataSetChanged") override fun onFastScrollStop() {
fun updateData(newData: List<T>) { homeModel.updateFastScrolling(false)
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()
}
} }
} }

View file

@ -17,13 +17,14 @@
package org.oxycblt.auxio.home.list package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup import androidx.lifecycle.LiveData
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.DisplayMode 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.SongViewHolder
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
@ -33,26 +34,24 @@ import org.oxycblt.auxio.ui.sliceArticle
* A [HomeListFragment] for showing a list of [Song]s. * A [HomeListFragment] for showing a list of [Song]s.
* @author * @author
*/ */
class SongListFragment : HomeListFragment() { class SongListFragment : HomeListFragment<Song>() {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { override val recyclerId = R.id.home_song_list
val homeAdapter = SongsAdapter(doOnClick = playbackModel::playSong, ::newMenu) override val homeAdapter = SongsAdapter(this)
setupRecycler(R.id.home_song_list, homeAdapter, homeModel.songs) override val homeData: LiveData<List<Song>>
} get() = homeModel.songs
override val listPopupProvider: (Int) -> String override fun getPopup(pos: Int): String {
get() = { idx -> val song = homeModel.songs.value!![pos]
val song = homeModel.songs.value!![idx]
// Change how we display the popup depending on the mode. // 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 // 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. // based off the names of the parent objects and not the child objects.
when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) { return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) {
// Name -> Use name // Name -> Use name
is Sort.ByName -> song.resolvedName.sliceArticle().first().uppercase() is Sort.ByName -> song.resolvedName.sliceArticle().first().uppercase()
// Artist -> Use Artist Name // Artist -> Use Artist Name
is Sort.ByArtist -> is Sort.ByArtist -> song.album.artist.resolvedName.sliceArticle().first().uppercase()
song.album.artist.resolvedName.sliceArticle().first().uppercase()
// Album -> Use Album Name // Album -> Use Album Name
is Sort.ByAlbum -> song.album.resolvedName.sliceArticle().first().uppercase() is Sort.ByAlbum -> song.album.resolvedName.sliceArticle().first().uppercase()
@ -62,18 +61,17 @@ class SongListFragment : HomeListFragment() {
} }
} }
inner class SongsAdapter( override fun onItemClick(item: Item) {
private val doOnClick: (data: Song) -> Unit, check(item is Song)
private val doOnLongClick: (view: View, data: Song) -> Unit, playbackModel.playSong(item)
) : 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) { override fun onOpenMenu(item: Item, anchor: View) {
holder.bind(data[position]) newMenu(anchor, item)
} }
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.content.ContentUris
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.view.View import org.oxycblt.auxio.ui.Item
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
// --- MUSIC MODELS --- // --- 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. */ /** [Item] variant that represents a music item. */
sealed class Music : Item() { sealed class Music : Item() {
/** The raw name of this item. */ /** The raw name of this item. */
@ -245,49 +237,3 @@ data class Genre(
val totalDuration: String val totalDuration: String
get() = songs.sumOf { it.seconds }.toDuration(false) 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()) { while (cursor.moveToNext()) {
// Genre names can be a normal name, an ID3v2 constant, or null. Normal names // Genre names can be a normal name, an ID3v2 constant, or null. Normal names
// are // are resolved as usual, but null values don't make sense and are often junk
// resolved as usual, but null values don't make sense and are often junk // anyway, so we skip genres that have them.
// anyway,
// so we skip genres that have them.
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue val name = cursor.getStringOrNull(nameIndex) ?: continue
val resolvedName = name.genreNameCompat ?: name val resolvedName = name.genreNameCompat ?: name

View file

@ -18,99 +18,39 @@
package org.oxycblt.auxio.playback.queue package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.databinding.ItemQueueSongBinding 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.music.Song
import org.oxycblt.auxio.ui.ActionHeaderViewHolder import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.BaseViewHolder import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.DiffCallback import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.HeaderViewHolder
import org.oxycblt.auxio.util.disableDropShadowCompat import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.stateList import org.oxycblt.auxio.util.stateList
import org.oxycblt.auxio.util.textSafe import org.oxycblt.auxio.util.textSafe
/** class NewQueueAdapter(listener: QueueItemListener) :
* The single adapter for both the Next Queue and the User Queue. MonoAdapter<Song, QueueItemListener, QueueSongViewHolder>(
* @param touchHelper The [ItemTouchHelper] ***containing*** [QueueDragCallback] to be used listener, QueueSongViewHolder.DIFFER) {
* @author OxygenCobalt override val creator = QueueSongViewHolder.CREATOR
*/
class QueueAdapter(private val touchHelper: ItemTouchHelper) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var data = mutableListOf<Item>()
private var listDiffer = AsyncListDiffer(this, DiffCallback())
override fun getItemCount(): Int = data.size
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
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { interface QueueItemListener {
return when (viewType) { fun onPickUp(viewHolder: RecyclerView.ViewHolder)
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) { class QueueSongViewHolder
when (val item = data[position]) { private constructor(
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, private val binding: ItemQueueSongBinding,
) : BaseViewHolder<Song>(binding) { ) : BindingViewHolder<Song, QueueItemListener>(binding.root) {
val bodyView: View val bodyView: View
get() = binding.body get() = binding.body
val backgroundView: View val backgroundView: View
@ -126,10 +66,10 @@ class QueueAdapter(private val touchHelper: ItemTouchHelper) :
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun onBind(data: Song) { override fun bind(item: Song, listener: QueueItemListener) {
binding.songAlbumCover.bindAlbumCover(data) binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = data.resolvedName binding.songName.textSafe = item.resolvedName
binding.songInfo.textSafe = data.resolvedArtistName binding.songInfo.textSafe = item.resolvedArtistName
binding.background.isInvisible = true binding.background.isInvisible = true
@ -140,15 +80,27 @@ class QueueAdapter(private val touchHelper: ItemTouchHelper) :
binding.songDragHandle.setOnTouchListener { _, motionEvent -> binding.songDragHandle.setOnTouchListener { _, motionEvent ->
binding.songDragHandle.performClick() binding.songDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) { if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(this) listener.onPickUp(this)
true true
} else false } else false
} }
binding.body.setOnLongClickListener { binding.body.setOnLongClickListener {
touchHelper.startDrag(this) listener.onPickUp(this)
true 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. * hot garbage. This shouldn't have *too many* UI bugs. I hope.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() { class QueueDragCallback(
private lateinit var queueAdapter: QueueAdapter private val playbackModel: PlaybackViewModel,
private val queueAdapter: NewQueueAdapter
) : ItemTouchHelper.Callback() {
private var shouldLift = true private var shouldLift = true
override fun getMovementFlags( 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 // 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 // work! To emulate it on my own, I check if this child is in a drag state and then animate
// an elevation change. // an elevation change.
val holder = viewHolder as QueueAdapter.QueueSongViewHolder val holder = viewHolder as QueueSongViewHolder
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
logD("Lifting queue item") logD("Lifting queue item")
@ -122,7 +124,7 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
// When an elevated item is cleared, we reset the elevation using another animation. // 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) { if (holder.itemView.translationZ != 0f) {
logD("Dropping queue item") logD("Dropping queue item")
@ -163,14 +165,6 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
override fun isLongPressDragEnabled(): Boolean = false 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 { companion object {
const val MINIMUM_INITIAL_DRAG_VELOCITY = 10 const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25 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.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment 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. * A [Fragment] that shows the queue and enables editing as well.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>() { class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemListener {
private val playbackModel: PlaybackViewModel by activityViewModels() 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 onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentQueueBinding, savedInstanceState: Bundle?) { 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.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.queueRecycler.apply { binding.queueRecycler.apply {
setHasFixedSize(true)
adapter = queueAdapter adapter = queueAdapter
helper.attachToRecyclerView(this) requireTouchHelper().attachToRecyclerView(this)
} }
// --- VIEWMODEL SETUP ---- // --- VIEWMODEL SETUP ----
lastShuffle = playbackModel.isShuffling.value playbackModel.nextUp.observe(viewLifecycleOwner, ::updateQueue)
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) { queue -> 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()) { if (queue.isEmpty()) {
findNavController().navigateUp() findNavController().navigateUp()
return@observe return
} }
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 package org.oxycblt.auxio.search
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre 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.music.Song
import org.oxycblt.auxio.ui.AlbumViewHolder import org.oxycblt.auxio.ui.AlbumViewHolder
import org.oxycblt.auxio.ui.ArtistViewHolder import org.oxycblt.auxio.ui.ArtistViewHolder
import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.GenreViewHolder 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 import org.oxycblt.auxio.ui.SongViewHolder
/** class NeoSearchAdapter(listener: MenuItemListener) :
* A Multi-ViewHolder adapter that displays the results of a search query. MultiAdapter<MenuItemListener>(listener, DIFFER) {
* @author OxygenCobalt override fun getCreatorFromItem(item: Item) =
*/ when (item) {
class SearchAdapter( is Song -> SongViewHolder.CREATOR
private val doOnClick: (data: Music) -> Unit, is Album -> AlbumViewHolder.CREATOR
private val doOnLongClick: (view: View, data: Music) -> Unit is Artist -> ArtistViewHolder.CREATOR
) : ListAdapter<Item, RecyclerView.ViewHolder>(DiffCallback<Item>()) { is Genre -> GenreViewHolder.CREATOR
is Header -> NewHeaderViewHolder.CREATOR
override fun getItemViewType(position: Int): Int { else -> null
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
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun getCreatorFromViewType(viewType: Int) =
return when (viewType) { when (viewType) {
IntegerTable.ITEM_TYPE_GENRE -> SongViewHolder.CREATOR.viewType -> SongViewHolder.CREATOR
GenreViewHolder.from(parent.context, doOnClick, doOnLongClick) AlbumViewHolder.CREATOR.viewType -> AlbumViewHolder.CREATOR
IntegerTable.ITEM_TYPE_ARTIST -> ArtistViewHolder.CREATOR.viewType -> ArtistViewHolder.CREATOR
ArtistViewHolder.from(parent.context, doOnClick, doOnLongClick) GenreViewHolder.CREATOR.viewType -> GenreViewHolder.CREATOR
IntegerTable.ITEM_TYPE_ALBUM -> NewHeaderViewHolder.CREATOR.viewType -> NewHeaderViewHolder.CREATOR
AlbumViewHolder.from(parent.context, doOnClick, doOnLongClick) else -> null
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 onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBind(
when (val item = getItem(position)) { viewHolder: RecyclerView.ViewHolder,
is Genre -> (holder as GenreViewHolder).bind(item) item: Item,
is Artist -> (holder as ArtistViewHolder).bind(item) listener: MenuItemListener
is Album -> (holder as AlbumViewHolder).bind(item) ) {
is Song -> (holder as SongViewHolder).bind(item) when (item) {
is Header -> (holder as HeaderViewHolder).bind(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 -> {} 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.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.postDelayed 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre 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.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel 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.ViewBindingFragment
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.getSystemServiceSafe import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.requireAttached
/** /**
* A [Fragment] that allows for the searching of the entire music library. * A [Fragment] that allows for the searching of the entire music library.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() { class SearchFragment : ViewBindingFragment<FragmentSearchBinding>(), MenuItemListener {
// SearchViewModel is only scoped to this Fragment // SearchViewModel is only scoped to this Fragment
private val searchModel: SearchViewModel by viewModels() private val searchModel: SearchViewModel by viewModels()
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val searchAdapter = NeoSearchAdapter(this)
private var imm: InputMethodManager? = null
private var launchedKeyboard = false private var launchedKeyboard = false
override fun onCreateBinding(inflater: LayoutInflater) = FragmentSearchBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentSearchBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) { 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 { binding.searchToolbar.apply {
menu.findItem(searchModel.filterMode?.itemId ?: R.id.option_filter_all).isChecked = true menu.findItem(searchModel.filterMode?.itemId ?: R.id.option_filter_all).isChecked = true
setNavigationOnClickListener { setNavigationOnClickListener {
imm.hide() requireImm().hide()
findNavController().navigateUp() findNavController().navigateUp()
} }
@ -94,7 +93,9 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
if (!launchedKeyboard) { if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown // Auto-open the keyboard when this view is shown
requestFocus() requestFocus()
postDelayed(200) { imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } postDelayed(200) {
requireImm().showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
launchedKeyboard = true launchedKeyboard = true
} }
@ -107,13 +108,11 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
searchModel.searchResults.observe(viewLifecycleOwner) { results -> searchModel.searchResults.observe(viewLifecycleOwner, ::updateResults)
updateResults(results, searchAdapter)
}
detailModel.navToItem.observe(viewLifecycleOwner) { item -> detailModel.navToItem.observe(viewLifecycleOwner) { item ->
handleNavigation(item) handleNavigation(item)
imm.hide() requireImm().hide()
} }
} }
@ -122,10 +121,47 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>() {
searchModel.setNavigating(false) 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() 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 // 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 // the query actually changes instead of once every re-creation event, but sadly
// that doesn't seem possible. // 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() { private fun InputMethodManager.hide() {
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS) 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 java.text.Normalizer
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R 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.Music
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode 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.ui.Sort
import org.oxycblt.auxio.util.logD 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.showToast 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 clipToPadding = false
} }
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onAttachedToWindow() {
super.onAttachedToWindow()
setHasFixedSize(true)
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding( updatePadding(
initialPadding.left, initialPadding.left,
initialPadding.top, 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 * 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 * it under the terms of the GNU General Public License as published by
@ -18,98 +18,53 @@
package org.oxycblt.auxio.ui package org.oxycblt.auxio.ui
import android.content.Context import android.content.Context
import android.view.View import org.oxycblt.auxio.IntegerTable
import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.coil.bindArtistImage import org.oxycblt.auxio.coil.bindArtistImage
import org.oxycblt.auxio.coil.bindGenreImage import org.oxycblt.auxio.coil.bindGenreImage
import org.oxycblt.auxio.databinding.ItemActionHeaderBinding
import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre 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.music.Song
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe import org.oxycblt.auxio.util.textSafe
/** class SongViewHolder private constructor(private val binding: ItemSongBinding) :
* A [RecyclerView.ViewHolder] that streamlines a lot of the common things across all viewholders. BindingViewHolder<Song, MenuItemListener>(binding.root) {
* @param T The datatype, inheriting [Item] for this ViewHolder. override fun bind(item: Song, listener: MenuItemListener) {
* @param binding Basic [ViewDataBinding] required to set up click listeners & sizing. binding.songAlbumCover.bindAlbumCover(item)
* @param doOnClick (Optional) Function that calls on a click. binding.songName.textSafe = item.resolvedName
* @param doOnLongClick (Optional) Functions that calls on a long-click. binding.songInfo.textSafe = item.resolvedArtistName
* @author OxygenCobalt binding.root.apply {
*/ setOnClickListener { listener.onItemClick(item) }
abstract class BaseViewHolder<T : Item>( setOnLongClickListener { view ->
private val binding: ViewBinding, listener.onOpenMenu(item, view)
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)
true 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 { companion object {
/** Create an instance of [SongViewHolder] */ val CREATOR =
fun from( object : Creator<SongViewHolder> {
context: Context, override val viewType: Int
doOnClick: (data: Song) -> Unit, get() = IntegerTable.ITEM_TYPE_SONG
doOnLongClick: (view: View, data: Song) -> Unit
): SongViewHolder { override fun create(context: Context) =
return SongViewHolder( SongViewHolder(ItemSongBinding.inflate(context.inflater))
ItemSongBinding.inflate(context.inflater), doOnClick, doOnLongClick) }
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 class AlbumViewHolder
private constructor( private constructor(
private val binding: ItemParentBinding, private val binding: ItemParentBinding,
doOnClick: (data: Album) -> Unit, ) : BindingViewHolder<Album, MenuItemListener>(binding.root) {
doOnLongClick: (view: View, data: Album) -> Unit
) : BaseViewHolder<Album>(binding, doOnClick, doOnLongClick) {
override fun onBind(data: Album) { override fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bindAlbumCover(data) binding.parentImage.bindAlbumCover(item)
binding.parentName.textSafe = data.resolvedName binding.parentName.textSafe = item.resolvedName
binding.parentInfo.textSafe = data.resolvedArtistName binding.parentInfo.textSafe = item.resolvedArtistName
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view ->
listener.onOpenMenu(item, view)
true
}
}
} }
companion object { companion object {
/** Create an instance of [AlbumViewHolder] */ val CREATOR =
fun from( object : Creator<AlbumViewHolder> {
context: Context, override val viewType: Int
doOnClick: (data: Album) -> Unit, get() = IntegerTable.ITEM_TYPE_ALBUM
doOnLongClick: (view: View, data: Album) -> Unit
): AlbumViewHolder { override fun create(context: Context) =
return AlbumViewHolder( AlbumViewHolder(ItemParentBinding.inflate(context.inflater))
ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick) }
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]. */ /** The Shared ViewHolder for a [Artist]. */
class ArtistViewHolder class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
private constructor( BindingViewHolder<Artist, MenuItemListener>(binding.root) {
private val binding: ItemParentBinding,
doOnClick: (Artist) -> Unit,
doOnLongClick: (view: View, data: Artist) -> Unit
) : BaseViewHolder<Artist>(binding, doOnClick, doOnLongClick) {
override fun onBind(data: Artist) { override fun bind(item: Artist, listener: MenuItemListener) {
binding.parentImage.bindArtistImage(data) binding.parentImage.bindArtistImage(item)
binding.parentName.textSafe = data.resolvedName binding.parentName.textSafe = item.resolvedName
binding.parentInfo.textSafe = binding.parentInfo.textSafe =
binding.context.getString( binding.context.getString(
R.string.fmt_two, R.string.fmt_two,
binding.context.getPluralSafe(R.plurals.fmt_album_count, data.albums.size), binding.context.getPluralSafe(R.plurals.fmt_album_count, item.albums.size),
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 { companion object {
/** Create an instance of [ArtistViewHolder] */ val CREATOR =
fun from( object : Creator<ArtistViewHolder> {
context: Context, override val viewType: Int
doOnClick: (Artist) -> Unit, get() = IntegerTable.ITEM_TYPE_ARTIST
doOnLongClick: (view: View, data: Artist) -> Unit
): ArtistViewHolder { override fun create(context: Context) =
return ArtistViewHolder( ArtistViewHolder(ItemParentBinding.inflate(context.inflater))
ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick) }
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 class GenreViewHolder
private constructor( private constructor(
private val binding: ItemParentBinding, private val binding: ItemParentBinding,
doOnClick: (Genre) -> Unit, ) : BindingViewHolder<Genre, MenuItemListener>(binding.root) {
doOnLongClick: (view: View, data: Genre) -> Unit
) : BaseViewHolder<Genre>(binding, doOnClick, doOnLongClick) {
override fun onBind(data: Genre) { override fun bind(item: Genre, listener: MenuItemListener) {
binding.parentImage.bindGenreImage(data) binding.parentImage.bindGenreImage(item)
binding.parentName.textSafe = data.resolvedName binding.parentName.textSafe = item.resolvedName
binding.parentInfo.textSafe = 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 { companion object {
/** Create an instance of [GenreViewHolder] */ val CREATOR =
fun from( object : Creator<GenreViewHolder> {
context: Context, override val viewType: Int
doOnClick: (Genre) -> Unit, get() = IntegerTable.ITEM_TYPE_GENRE
doOnLongClick: (view: View, data: Genre) -> Unit
): GenreViewHolder { override fun create(context: Context) =
return GenreViewHolder( GenreViewHolder(ItemParentBinding.inflate(context.inflater))
ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick) }
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] */ /** The Shared ViewHolder for a [Header]. Instantiation should be done with [from] */
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : class NewHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
BaseViewHolder<Header>(binding) { BindingViewHolder<Header, Unit>(binding.root) {
override fun onBind(data: Header) { override fun bind(item: Header, listener: Unit) {
binding.title.textSafe = binding.context.getString(data.string) binding.title.textSafe = binding.context.getString(item.string)
} }
companion object { companion object {
/** Create an instance of [HeaderViewHolder] */ val CREATOR =
fun from(context: Context): HeaderViewHolder { object : Creator<NewHeaderViewHolder> {
return HeaderViewHolder(ItemHeaderBinding.inflate(context.inflater)) override val viewType: Int
} get() = IntegerTable.ITEM_TYPE_HEADER
}
override fun create(context: Context) =
NewHeaderViewHolder(ItemHeaderBinding.inflate(context.inflater))
} }
/** The Shared ViewHolder for an [ActionHeader]. Instantiation should be done with [from] */ val DIFFER =
class ActionHeaderViewHolder private constructor(private val binding: ItemActionHeaderBinding) : object : ItemDiffCallback<Header>() {
BaseViewHolder<ActionHeader>(binding) { override fun areItemsTheSame(oldItem: Header, newItem: Header): Boolean =
oldItem.string == newItem.string
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))
} }
} }
} }

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.util
import android.database.Cursor import android.database.Cursor
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.os.Looper 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 * 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" "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. */ /** Returns whether a recyclerview can scroll. */
fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height

View file

@ -26,11 +26,12 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:maxLines="1" 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:textAlignment="center"
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge" android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
android:textColor="@color/sel_accented_secondary" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_name" app:layout_constraintEnd_toStartOf="@+id/song_name"
app:layout_constraintHorizontal_bias="0.5" app:layout_constraintHorizontal_bias="0.5"

View file

@ -29,8 +29,8 @@
app:layout_constraintBottom_toTopOf="@id/header_divider" app:layout_constraintBottom_toTopOf="@id/header_divider"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" android:contentDescription="@string/lbl_sort"
tools:src="@drawable/ic_sort" /> android:src="@drawable/ic_sort" />
<com.google.android.material.divider.MaterialDivider <com.google.android.material.divider.MaterialDivider
android:id="@+id/header_divider" 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_label_larger">16sp</dimen>
<dimen name="text_size_ext_title_mid_large">18sp</dimen> <dimen name="text_size_ext_title_mid_large">18sp</dimen>
<dimen name="text_size_track_number">22sp</dimen>
<!-- Misc --> <!-- Misc -->
<dimen name="elevation_small">2dp</dimen> <dimen name="elevation_small">2dp</dimen>