diff --git a/CHANGELOG.md b/CHANGELOG.md index 870a7aa6d..dc5f30354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese ] - Switched to spotless and ktfmt instead of ktlint - Migrated constants to centralized table +- Removed databinding [Greatly reduces compile times] - A bunch of internal view implementation improvements ## v2.2.2 diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt index 621265614..9ee297618 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt @@ -28,7 +28,7 @@ import org.oxycblt.auxio.coil.GenreImageFetcher import org.oxycblt.auxio.coil.MusicKeyer import org.oxycblt.auxio.settings.SettingsManager -/** TODO: Rework RecyclerView management and item dragging */ +/** TODO: Rework null-safety/usage of requireNotNull */ @Suppress("UNUSED") class AuxioApp : Application(), ImageLoaderFactory { override fun onCreate() { diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 182544606..c4cfbb728 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -29,8 +29,8 @@ object IntegerTable { const val ITEM_TYPE_GENRE = 0xA003 /** HeaderViewHolder */ const val ITEM_TYPE_HEADER = 0xA004 - /** ActionHeaderViewHolder */ - const val ITEM_TYPE_ACTION_HEADER = 0xA005 + /** SortHeaderViewHolder */ + const val ITEM_TYPE_SORT_HEADER = 0xA005 /** AlbumDetailViewHolder */ const val ITEM_TYPE_ALBUM_DETAIL = 0xA006 diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 3c90a7a32..f27a78dda 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail import android.content.Context import android.os.Bundle +import android.view.View import androidx.core.view.children import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -26,15 +27,17 @@ import androidx.recyclerview.widget.LinearSmoothScroller import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter -import org.oxycblt.auxio.music.ActionHeader +import org.oxycblt.auxio.detail.recycler.AlbumDetailItemListener +import org.oxycblt.auxio.detail.recycler.SortHeader import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackMode -import org.oxycblt.auxio.ui.ActionMenu +import org.oxycblt.auxio.ui.Header +import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.newMenu +import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -44,28 +47,22 @@ import org.oxycblt.auxio.util.showToast * The [DetailFragment] for an album. * @author OxygenCobalt */ -class AlbumDetailFragment : DetailFragment() { +class AlbumDetailFragment : DetailFragment(), AlbumDetailItemListener { private val args: AlbumDetailFragmentArgs by navArgs() + private val detailAdapter = AlbumDetailAdapter(this) override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { detailModel.setAlbumId(args.albumId) - val detailAdapter = - AlbumDetailAdapter( - playbackModel, - detailModel, - doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) }, - doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ALBUM) }) - - setupToolbar(detailModel.curAlbum.value!!, R.menu.menu_album_detail) { itemId -> + setupToolbar(detailModel.currentAlbum.value!!, R.menu.menu_album_detail) { itemId -> when (itemId) { R.id.action_play_next -> { - playbackModel.playNext(detailModel.curAlbum.value!!) + playbackModel.playNext(detailModel.currentAlbum.value!!) requireContext().showToast(R.string.lbl_queue_added) true } R.id.action_queue_add -> { - playbackModel.addToQueue(detailModel.curAlbum.value!!) + playbackModel.addToQueue(detailModel.currentAlbum.value!!) requireContext().showToast(R.string.lbl_queue_added) true } @@ -73,20 +70,17 @@ class AlbumDetailFragment : DetailFragment() { } } - setupRecycler(detailAdapter) { pos -> - val item = detailAdapter.currentList[pos] - item is Header || item is ActionHeader || item is Album + requireBinding().detailRecycler.apply { + adapter = detailAdapter + applySpans { pos -> + val item = detailAdapter.currentList[pos] + item is Header || item is SortHeader || item is Album + } } // -- VIEWMODEL SETUP --- - detailModel.albumData.observe(viewLifecycleOwner, detailAdapter::submitList) - - detailModel.showMenu.observe(viewLifecycleOwner) { config -> - if (config != null) { - showMenu(config) { id -> id == R.id.option_sort_asc } - } - } + detailModel.albumData.observe(viewLifecycleOwner) { list -> detailAdapter.submitList(list) } detailModel.navToItem.observe(viewLifecycleOwner) { item -> handleNavigation(item, detailAdapter) @@ -96,13 +90,46 @@ class AlbumDetailFragment : DetailFragment() { playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) } } + override fun onItemClick(item: Item) { + if (item is Song) { + playbackModel.playSong(item, PlaybackMode.IN_ALBUM) + } + } + + override fun onOpenMenu(item: Item, anchor: View) { + newMenu(anchor, item) + } + + override fun onPlayParent() { + playbackModel.playAlbum(requireNotNull(detailModel.currentAlbum.value), false) + } + + override fun onShuffleParent() { + playbackModel.playAlbum(requireNotNull(detailModel.currentAlbum.value), true) + } + + override fun onShowSortMenu(anchor: View) { + showSortMenu( + anchor, + detailModel.albumSort, + onConfirm = { detailModel.albumSort = it }, + showItem = { it == R.id.option_sort_asc }) + } + + override fun onNavigateToArtist() { + findNavController() + .navigate( + AlbumDetailFragmentDirections.actionShowArtist( + requireNotNull(detailModel.currentAlbum.value).artist.id)) + } + private fun handleNavigation(item: Music?, adapter: AlbumDetailAdapter) { val binding = requireBinding() when (item) { // Songs should be scrolled to if the album matches, or a new detail // fragment should be launched otherwise. is Song -> { - if (detailModel.curAlbum.value!!.id == item.album.id) { + if (detailModel.currentAlbum.value!!.id == item.album.id) { logD("Navigating to a song in this album") scrollToItem(item.id, adapter) detailModel.finishNavToItem() @@ -116,7 +143,7 @@ class AlbumDetailFragment : DetailFragment() { // If the album matches, no need to do anything. Otherwise launch a new // detail fragment. is Album -> { - if (detailModel.curAlbum.value!!.id == item.id) { + if (detailModel.currentAlbum.value!!.id == item.id) { logD("Navigating to the top of this album") binding.detailRecycler.scrollToPosition(0) detailModel.finishNavToItem() @@ -169,7 +196,7 @@ class AlbumDetailFragment : DetailFragment() { } if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM && - playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id) { + playbackModel.parent.value?.id == detailModel.currentAlbum.value!!.id) { adapter.highlightSong(song, binding.detailRecycler) } else { // Clear the ViewHolders if the mode isn't ALL_SONGS diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 3e1d53629..314557844 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -18,21 +18,24 @@ package org.oxycblt.auxio.detail import android.os.Bundle +import android.view.View import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter -import org.oxycblt.auxio.music.ActionHeader +import org.oxycblt.auxio.detail.recycler.DetailItemListener +import org.oxycblt.auxio.detail.recycler.SortHeader import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackMode -import org.oxycblt.auxio.ui.ActionMenu +import org.oxycblt.auxio.ui.Header +import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.newMenu +import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -40,40 +43,27 @@ import org.oxycblt.auxio.util.logW * The [DetailFragment] for an artist. * @author OxygenCobalt */ -class ArtistDetailFragment : DetailFragment() { +class ArtistDetailFragment : DetailFragment(), DetailItemListener { private val args: ArtistDetailFragmentArgs by navArgs() + private val detailAdapter = ArtistDetailAdapter(this) override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { detailModel.setArtistId(args.artistId) - val detailAdapter = - ArtistDetailAdapter( - playbackModel, - doOnClick = { data -> - if (!detailModel.isNavigating) { - detailModel.setNavigating(true) - findNavController() - .navigate(ArtistDetailFragmentDirections.actionShowAlbum(data.id)) - } - }, - doOnSongClick = { data -> playbackModel.playSong(data, PlaybackMode.IN_ARTIST) }, - doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ARTIST) }) - setupToolbar(detailModel.currentArtist.value!!) - setupRecycler(detailAdapter) { pos -> - // If the item is an ActionHeader we need to also make the item full-width - val item = detailAdapter.currentList[pos] - item is Header || item is ActionHeader || item is Artist + requireBinding().detailRecycler.apply { + adapter = detailAdapter + applySpans { pos -> + // If the item is an ActionHeader we need to also make the item full-width + val item = detailAdapter.currentList[pos] + item is Header || item is SortHeader || item is Artist + } } // --- VIEWMODEL SETUP --- - detailModel.artistData.observe(viewLifecycleOwner, detailAdapter::submitList) - - detailModel.showMenu.observe(viewLifecycleOwner) { config -> - if (config != null) { - showMenu(config) { id -> id != R.id.option_sort_artist } - } + detailModel.artistData.observe(viewLifecycleOwner) { list -> + detailAdapter.submitList(list) } detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation) @@ -87,6 +77,35 @@ class ArtistDetailFragment : DetailFragment() { } } + override fun onItemClick(item: Item) { + when (item) { + is Song -> playbackModel.playSong(item, PlaybackMode.IN_ARTIST) + is Album -> + findNavController() + .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id)) + } + } + + override fun onOpenMenu(item: Item, anchor: View) { + newMenu(anchor, item) + } + + override fun onPlayParent() { + playbackModel.playArtist(requireNotNull(detailModel.currentArtist.value), false) + } + + override fun onShuffleParent() { + playbackModel.playArtist(requireNotNull(detailModel.currentArtist.value), true) + } + + override fun onShowSortMenu(anchor: View) { + showSortMenu( + anchor, + detailModel.artistSort, + onConfirm = { detailModel.artistSort = it }, + showItem = { id -> id != R.id.option_sort_artist }) + } + private fun handleNavigation(item: Music?) { val binding = requireBinding() diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt index 4b199651b..7f14266d8 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt @@ -18,19 +18,19 @@ package org.oxycblt.auxio.detail import android.view.LayoutInflater +import android.view.View import androidx.annotation.MenuRes import androidx.appcompat.widget.PopupMenu import androidx.core.view.children import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.logD /** @@ -49,10 +49,9 @@ abstract class DetailFragment : ViewBindingFragment() { detailModel.setNavigating(false) } - override fun onStop() { - super.onStop() - // Cancel all pending menus when this fragment stops to prevent bugs/crashes - detailModel.finishShowMenu(null) + override fun onDestroyBinding(binding: FragmentDetailBinding) { + super.onDestroyBinding(binding) + binding.detailRecycler.adapter = null } /** @@ -81,55 +80,44 @@ abstract class DetailFragment : ViewBindingFragment() { } } - /** Shortcut method for recyclerview setup */ - protected fun setupRecycler( - detailAdapter: RecyclerView.Adapter, - gridLookup: (Int) -> Boolean - ) { - requireBinding().detailRecycler.apply { - adapter = detailAdapter - setHasFixedSize(true) - applySpans(gridLookup) - } - } - /** * Shortcut method for spinning up the sorting [PopupMenu] - * @param config The initial configuration to apply to the menu. This is provided by - * [DetailViewModel.showMenu]. + * @param anchor The view to anchor the sort menu to + * @param sort The initial sort + * @param onConfirm What to do when the sort is confirmed * @param showItem Which menu items to keep */ - protected fun showMenu( - config: DetailViewModel.MenuConfig, - showItem: ((Int) -> Boolean)? = null + protected fun showSortMenu( + anchor: View, + sort: Sort, + onConfirm: (Sort) -> Unit, + showItem: ((Int) -> Boolean)? = null, ) { - logD("Launching menu [$config]") + logD("Launching menu") - PopupMenu(config.anchor.context, config.anchor).apply { + PopupMenu(anchor.context, anchor).apply { inflate(R.menu.menu_detail_sort) setOnMenuItemClickListener { item -> if (item.itemId == R.id.option_sort_asc) { item.isChecked = !item.isChecked - detailModel.finishShowMenu(config.sortMode.ascending(item.isChecked)) + onConfirm(sort.ascending(item.isChecked)) } else { item.isChecked = true - detailModel.finishShowMenu(config.sortMode.assignId(item.itemId)) + onConfirm(requireNotNull(sort.assignId(item.itemId))) } true } - setOnDismissListener { detailModel.finishShowMenu(null) } - if (showItem != null) { for (item in menu.children) { item.isVisible = showItem(item.itemId) } } - menu.findItem(config.sortMode.itemId).isChecked = true - menu.findItem(R.id.option_sort_asc).isChecked = config.sortMode.isAscending + menu.findItem(sort.itemId).isChecked = true + menu.findItem(R.id.option_sort_asc).isChecked = sort.isAscending show() } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index fc8281d3e..bfd716126 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -17,21 +17,19 @@ package org.oxycblt.auxio.detail -import android.view.View import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.ActionHeader +import org.oxycblt.auxio.detail.recycler.SortHeader import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Header -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.settings.SettingsManager -import org.oxycblt.auxio.ui.DisplayMode +import org.oxycblt.auxio.ui.Header +import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.util.logD @@ -44,14 +42,23 @@ import org.oxycblt.auxio.util.logD * @author OxygenCobalt */ class DetailViewModel : ViewModel() { + private val settingsManager = SettingsManager.getInstance() + private val mCurrentAlbum = MutableLiveData() - val curAlbum: LiveData + val currentAlbum: LiveData get() = mCurrentAlbum private val mAlbumData = MutableLiveData(listOf()) val albumData: LiveData> get() = mAlbumData + var albumSort: Sort + get() = settingsManager.detailAlbumSort + set(value) { + settingsManager.detailAlbumSort = value + refreshAlbumData() + } + private val mCurrentArtist = MutableLiveData() val currentArtist: LiveData get() = mCurrentArtist @@ -59,6 +66,13 @@ class DetailViewModel : ViewModel() { private val mArtistData = MutableLiveData(listOf()) val artistData: LiveData> = mArtistData + var artistSort: Sort + get() = settingsManager.detailArtistSort + set(value) { + settingsManager.detailArtistSort = value + refreshArtistData() + } + private val mCurrentGenre = MutableLiveData() val currentGenre: LiveData get() = mCurrentGenre @@ -66,10 +80,12 @@ class DetailViewModel : ViewModel() { private val mGenreData = MutableLiveData(listOf()) val genreData: LiveData> = mGenreData - data class MenuConfig(val anchor: View, val sortMode: Sort) - - private val mShowMenu = MutableLiveData(null) - val showMenu: LiveData = mShowMenu + var genreSort: Sort + get() = settingsManager.detailGenreSort + set(value) { + settingsManager.detailGenreSort = value + refreshGenreData() + } private val mNavToItem = MutableLiveData() @@ -80,9 +96,6 @@ class DetailViewModel : ViewModel() { var isNavigating = false private set - private var currentMenuContext: DisplayMode? = null - private val settingsManager = SettingsManager.getInstance() - fun setAlbumId(id: Long) { if (mCurrentAlbum.value?.id == id) return val musicStore = MusicStore.requireInstance() @@ -104,32 +117,6 @@ class DetailViewModel : ViewModel() { refreshGenreData() } - /** Mark that the menu process is done with the new [Sort]. Pass null if there was no change. */ - fun finishShowMenu(newMode: Sort?) { - mShowMenu.value = null - - if (newMode != null) { - logD("Applying new sort mode") - when (currentMenuContext) { - DisplayMode.SHOW_ALBUMS -> { - settingsManager.detailAlbumSort = newMode - refreshAlbumData() - } - DisplayMode.SHOW_ARTISTS -> { - settingsManager.detailArtistSort = newMode - refreshArtistData() - } - DisplayMode.SHOW_GENRES -> { - settingsManager.detailGenreSort = newMode - refreshGenreData() - } - else -> {} - } - } - - currentMenuContext = null - } - /** Navigate to an item, whether a song/album/artist */ fun navToItem(item: Music) { mNavToItem.value = item @@ -150,17 +137,7 @@ class DetailViewModel : ViewModel() { val genre = requireNotNull(currentGenre.value) val data = mutableListOf(genre) - data.add( - ActionHeader( - id = -2, - string = R.string.lbl_songs, - icon = R.drawable.ic_sort, - desc = R.string.lbl_sort, - onClick = { view -> - currentMenuContext = DisplayMode.SHOW_GENRES - mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort) - })) - + data.add(SortHeader(-2, R.string.lbl_songs)) data.addAll(settingsManager.detailGenreSort.genre(currentGenre.value!!)) mGenreData.value = data @@ -171,21 +148,9 @@ class DetailViewModel : ViewModel() { val artist = requireNotNull(currentArtist.value) val data = mutableListOf(artist) - data.add(Header(id = -2, string = R.string.lbl_albums)) - + data.add(Header(-2, R.string.lbl_albums)) data.addAll(Sort.ByYear(false).albums(artist.albums)) - - data.add( - ActionHeader( - id = -3, - string = R.string.lbl_songs, - icon = R.drawable.ic_sort, - desc = R.string.lbl_sort, - onClick = { view -> - currentMenuContext = DisplayMode.SHOW_ARTISTS - mShowMenu.value = MenuConfig(view, settingsManager.detailArtistSort) - })) - + data.add(SortHeader(-3, R.string.lbl_songs)) data.addAll(settingsManager.detailArtistSort.artist(artist)) mArtistData.value = data.toList() @@ -193,21 +158,11 @@ class DetailViewModel : ViewModel() { private fun refreshAlbumData() { logD("Refreshing album data") - val album = requireNotNull(curAlbum.value) + val album = requireNotNull(currentAlbum.value) val data = mutableListOf(album) - data.add( - ActionHeader( - id = -2, - string = R.string.lbl_songs, - icon = R.drawable.ic_sort, - desc = R.string.lbl_sort, - onClick = { view -> - currentMenuContext = DisplayMode.SHOW_ALBUMS - mShowMenu.value = MenuConfig(view, settingsManager.detailAlbumSort) - })) - - data.addAll(settingsManager.detailAlbumSort.album(curAlbum.value!!)) + data.add(SortHeader(id = -2, R.string.lbl_albums)) + data.addAll(settingsManager.detailAlbumSort.album(currentAlbum.value!!)) mAlbumData.value = data } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 12b755de8..7f4733773 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -18,20 +18,23 @@ package org.oxycblt.auxio.detail import android.os.Bundle +import android.view.View import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import org.oxycblt.auxio.databinding.FragmentDetailBinding +import org.oxycblt.auxio.detail.recycler.DetailItemListener import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter -import org.oxycblt.auxio.music.ActionHeader +import org.oxycblt.auxio.detail.recycler.SortHeader import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackMode -import org.oxycblt.auxio.ui.ActionMenu +import org.oxycblt.auxio.ui.Header +import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.newMenu +import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -39,39 +42,56 @@ import org.oxycblt.auxio.util.logW * The [DetailFragment] for a genre. * @author OxygenCobalt */ -class GenreDetailFragment : DetailFragment() { +class GenreDetailFragment : DetailFragment(), DetailItemListener { private val args: GenreDetailFragmentArgs by navArgs() + private val detailAdapter = GenreDetailAdapter(this) override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { detailModel.setGenreId(args.genreId) - val detailAdapter = - GenreDetailAdapter( - playbackModel, - doOnClick = { song -> playbackModel.playSong(song, PlaybackMode.IN_GENRE) }, - doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_GENRE) }) - setupToolbar(detailModel.currentGenre.value!!) - setupRecycler(detailAdapter) { pos -> - val item = detailAdapter.currentList[pos] - item is Header || item is ActionHeader || item is Genre + binding.detailRecycler.apply { + adapter = detailAdapter + applySpans { pos -> + val item = detailAdapter.currentList[pos] + item is Header || item is SortHeader || item is Genre + } } // --- VIEWMODEL SETUP --- - detailModel.genreData.observe(viewLifecycleOwner, detailAdapter::submitList) + detailModel.genreData.observe(viewLifecycleOwner) { list -> detailAdapter.submitList(list) } detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation) playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) } + } - detailModel.showMenu.observe(viewLifecycleOwner) { config -> - if (config != null) { - showMenu(config) - } + override fun onItemClick(item: Item) { + when (item) { + is Song -> playbackModel.playSong(item, PlaybackMode.IN_GENRE) + is Album -> + findNavController() + .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id)) } } + override fun onOpenMenu(item: Item, anchor: View) { + newMenu(anchor, item) + } + + override fun onPlayParent() { + playbackModel.playGenre(requireNotNull(detailModel.currentGenre.value), false) + } + + override fun onShuffleParent() { + playbackModel.playGenre(requireNotNull(detailModel.currentGenre.value), true) + } + + override fun onShowSortMenu(anchor: View) { + showSortMenu(anchor, detailModel.genreSort, onConfirm = { detailModel.genreSort = it }) + } + private fun handleNavigation(item: Music?) { when (item) { // All items will launch new detail fragments. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt index dbcc36ddd..063e5c875 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt @@ -17,82 +17,71 @@ package org.oxycblt.auxio.detail.recycler -import android.view.View -import android.view.ViewGroup +import android.content.Context import androidx.core.view.isInvisible -import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.databinding.ItemAlbumSongBinding import org.oxycblt.auxio.databinding.ItemDetailBinding -import org.oxycblt.auxio.detail.DetailViewModel -import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.toDuration -import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ActionHeaderViewHolder -import org.oxycblt.auxio.ui.BaseViewHolder -import org.oxycblt.auxio.ui.DiffCallback +import org.oxycblt.auxio.ui.BindingViewHolder +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.ItemDiffCallback +import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.textSafe /** - * An adapter for displaying the details and [Song]s of an [Album] + * An adapter for displaying [Album] information and it's children. * @author OxygenCobalt */ -class AlbumDetailAdapter( - private val playbackModel: PlaybackViewModel, - private val detailModel: DetailViewModel, - private val doOnClick: (data: Song) -> Unit, - private val doOnLongClick: (view: View, data: Song) -> Unit -) : ListAdapter(DiffCallback()) { - private var currentSong: Song? = null - private var currentHolder: Highlightable? = null +class AlbumDetailAdapter(listener: AlbumDetailItemListener) : + DetailAdapter(listener, DIFFER) { + private var highlightedSong: Song? = null + private var highlightedViewHolder: Highlightable? = null - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is Album -> IntegerTable.ITEM_TYPE_ALBUM_DETAIL - is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER - is Song -> IntegerTable.ITEM_TYPE_ALBUM_SONG - else -> -1 - } - } + override fun getCreatorFromItem(item: Item) = + super.getCreatorFromItem(item) + ?: when (item) { + is Album -> AlbumDetailViewHolder.CREATOR + is Song -> AlbumSongViewHolder.CREATOR + else -> null + } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - IntegerTable.ITEM_TYPE_ALBUM_DETAIL -> - AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) - IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context) - IntegerTable.ITEM_TYPE_ALBUM_SONG -> - AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater)) - else -> error("Invalid ViewHolder item type $viewType") - } - } + override fun getCreatorFromViewType(viewType: Int) = + super.getCreatorFromViewType(viewType) + ?: when (viewType) { + AlbumDetailViewHolder.CREATOR.viewType -> AlbumDetailViewHolder.CREATOR + AlbumSongViewHolder.CREATOR.viewType -> AlbumSongViewHolder.CREATOR + else -> null + } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - val item = getItem(position) + override fun onBind( + viewHolder: RecyclerView.ViewHolder, + item: Item, + listener: AlbumDetailItemListener + ) { + super.onBind(viewHolder, item, listener) when (item) { - is Album -> (holder as AlbumDetailViewHolder).bind(item) - is Song -> (holder as AlbumSongViewHolder).bind(item) - is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) - else -> {} + is Album -> (viewHolder as AlbumDetailViewHolder).bind(item, listener) + is Song -> (viewHolder as AlbumSongViewHolder).bind(item, listener) } + } - if (holder is Highlightable) { - if (item.id == currentSong?.id) { - // Reset the last ViewHolder before assigning the new, correct one to be highlighted - currentHolder?.setHighlighted(false) - currentHolder = holder - holder.setHighlighted(true) - } else { - holder.setHighlighted(false) - } + override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) { + if (item is Song && item.id == highlightedSong?.id) { + // Reset the last ViewHolder before assigning the new, correct one to be highlighted + highlightedViewHolder?.setHighlighted(false) + highlightedViewHolder = viewHolder + viewHolder.setHighlighted(true) + } else { + viewHolder.setHighlighted(false) } } @@ -101,89 +90,134 @@ class AlbumDetailAdapter( * @param recycler The recyclerview the highlighting should act on. */ fun highlightSong(song: Song?, recycler: RecyclerView) { - if (song == currentSong) return // Already highlighting this ViewHolder - - // Clear the current ViewHolder since it's invalid - currentHolder?.setHighlighted(false) - currentHolder = null - currentSong = song - - if (song != null) { - // Use existing data instead of having to re-sort it. - val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song } - - // Check if the ViewHolder for this song is visible, if it is then highlight it. - // If the ViewHolder is not visible, then the adapter should take care of it if - // it does become visible. - recycler.layoutManager?.findViewByPosition(pos)?.let { child -> - recycler.getChildViewHolder(child)?.let { - currentHolder = it as Highlightable - currentHolder?.setHighlighted(true) - } - } - } + if (song == highlightedSong) return + highlightedSong = song + highlightedViewHolder?.setHighlighted(false) + highlightedViewHolder = highlightItem(song, recycler) } - inner class AlbumDetailViewHolder(private val binding: ItemDetailBinding) : - BaseViewHolder(binding) { - - override fun onBind(data: Album) { - binding.detailCover.apply { - bindAlbumCover(data) - contentDescription = context.getString(R.string.desc_album_cover, data.resolvedName) - } - - binding.detailName.textSafe = data.resolvedName - - binding.detailSubhead.apply { - textSafe = data.artist.resolvedName - setOnClickListener { detailModel.navToItem(data.artist) } - } - - binding.detailInfo.apply { - text = - context.getString( - R.string.fmt_three, - data.year?.toString() ?: context.getString(R.string.def_date), - context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size), - data.totalDuration) - } - - binding.detailPlayButton.setOnClickListener { playbackModel.playAlbum(data, false) } - - binding.detailShuffleButton.setOnClickListener { playbackModel.playAlbum(data, true) } - } - } - - inner class AlbumSongViewHolder( - private val binding: ItemAlbumSongBinding, - ) : BaseViewHolder(binding, doOnClick, doOnLongClick), Highlightable { - override fun onBind(data: Song) { - // Hide the track number view if the song does not have a track. - if (data.track != null) { - binding.songTrack.apply { - textSafe = context.getString(R.string.fmt_number, data.track) - isInvisible = false + companion object { + private val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + return when { + oldItem is Album && newItem is Album -> + AlbumDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + oldItem is SortHeader && newItem is SortHeader -> + SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + oldItem is Song && newItem is Song -> + AlbumSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem) + } } - - binding.songTrackPlaceholder.isInvisible = true - } else { - binding.songTrack.apply { - textSafe = "" - isInvisible = true - } - - binding.songTrackPlaceholder.isInvisible = false } - - binding.songName.textSafe = data.resolvedName - binding.songDuration.textSafe = data.seconds.toDuration(false) - } - - override fun setHighlighted(isHighlighted: Boolean) { - binding.songName.isActivated = isHighlighted - binding.songTrack.isActivated = isHighlighted - binding.songTrackPlaceholder.isActivated = isHighlighted - } + } +} + +interface AlbumDetailItemListener : DetailItemListener { + fun onNavigateToArtist() +} + +private class AlbumDetailViewHolder private constructor(private val binding: ItemDetailBinding) : + BindingViewHolder(binding.root) { + + override fun bind(item: Album, listener: AlbumDetailItemListener) { + binding.detailCover.bindAlbumCover(item) + binding.detailName.textSafe = item.resolvedName + + binding.detailSubhead.apply { + textSafe = item.resolvedArtistName + setOnClickListener { listener.onNavigateToArtist() } + } + + binding.detailInfo.apply { + text = + context.getString( + R.string.fmt_three, + item.year?.toString() ?: context.getString(R.string.def_date), + context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size), + item.totalDuration) + } + + binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } + binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } + } + + companion object { + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_ALBUM_DETAIL + + override fun create(context: Context) = + AlbumDetailViewHolder(ItemDetailBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Album, newItem: Album) = + oldItem.resolvedName == newItem.resolvedName && + oldItem.resolvedArtistName == newItem.resolvedArtistName && + oldItem.year == newItem.year && + oldItem.songs.size == newItem.songs.size && + oldItem.totalDuration == newItem.totalDuration + } + } +} + +private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) : + BindingViewHolder(binding.root), Highlightable { + override fun bind(item: Song, listener: MenuItemListener) { + // Hide the track number view if the song does not have a track. + if (item.track != null) { + binding.songTrack.apply { + textSafe = context.getString(R.string.fmt_number, item.track) + isInvisible = false + } + + binding.songTrackPlaceholder.isInvisible = true + } else { + binding.songTrack.apply { + textSafe = "" + isInvisible = true + } + + binding.songTrackPlaceholder.isInvisible = false + } + + binding.songName.textSafe = item.resolvedName + binding.songDuration.textSafe = item.seconds.toDuration(false) + + binding.root.apply { + setOnClickListener { listener.onItemClick(item) } + setOnLongClickListener { view -> + listener.onOpenMenu(item, view) + true + } + } + } + + override fun setHighlighted(isHighlighted: Boolean) { + binding.songName.isActivated = isHighlighted + binding.songTrack.isActivated = isHighlighted + binding.songTrackPlaceholder.isActivated = isHighlighted + } + + companion object { + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_ALBUM_SONG + + override fun create(context: Context) = + AlbumSongViewHolder(ItemAlbumSongBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Song, newItem: Song) = + oldItem.resolvedName == newItem.resolvedName && + oldItem.duration == newItem.duration + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt index 01a22dd2d..a23944307 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt @@ -17,9 +17,7 @@ package org.oxycblt.auxio.detail.recycler -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter +import android.content.Context import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -28,88 +26,76 @@ import org.oxycblt.auxio.coil.bindArtistImage import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding -import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Header -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ActionHeaderViewHolder -import org.oxycblt.auxio.ui.BaseViewHolder -import org.oxycblt.auxio.ui.DiffCallback -import org.oxycblt.auxio.ui.HeaderViewHolder +import org.oxycblt.auxio.ui.ArtistViewHolder +import org.oxycblt.auxio.ui.BindingViewHolder +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.ItemDiffCallback +import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.textSafe /** - * An adapter for displaying the [Album]s and [Song]s of an artist. + * An adapter for displaying [Artist] information and it's children. Unlike the other adapters, this + * one actually contains both album information and song information. * @author OxygenCobalt */ -class ArtistDetailAdapter( - private val playbackModel: PlaybackViewModel, - private val doOnClick: (data: Album) -> Unit, - private val doOnSongClick: (data: Song) -> Unit, - private val doOnLongClick: (view: View, data: Item) -> Unit, -) : ListAdapter(DiffCallback()) { +class ArtistDetailAdapter(listener: DetailItemListener) : + DetailAdapter(listener, DIFFER) { private var currentAlbum: Album? = null private var currentAlbumHolder: Highlightable? = null private var currentSong: Song? = null private var currentSongHolder: Highlightable? = null - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is Artist -> IntegerTable.ITEM_TYPE_ARTIST_DETAIL - is Album -> IntegerTable.ITEM_TYPE_ARTIST_ALBUM - is Song -> IntegerTable.ITEM_TYPE_ARTIST_SONG - is Header -> IntegerTable.ITEM_TYPE_HEADER - is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER - else -> -1 - } - } + override fun getCreatorFromItem(item: Item) = + super.getCreatorFromItem(item) + ?: when (item) { + is Artist -> ArtistDetailViewHolder.CREATOR + is Album -> ArtistAlbumViewHolder.CREATOR + is Song -> ArtistSongViewHolder.CREATOR + else -> null + } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - IntegerTable.ITEM_TYPE_ARTIST_DETAIL -> - ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) - IntegerTable.ITEM_TYPE_ARTIST_ALBUM -> - ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater)) - IntegerTable.ITEM_TYPE_ARTIST_SONG -> - ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater)) - IntegerTable.ITEM_TYPE_HEADER -> HeaderViewHolder.from(parent.context) - IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context) - else -> error("Invalid ViewHolder item type $viewType") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - val item = getItem(position) + override fun getCreatorFromViewType(viewType: Int) = + super.getCreatorFromViewType(viewType) + ?: when (viewType) { + ArtistDetailViewHolder.CREATOR.viewType -> ArtistDetailViewHolder.CREATOR + ArtistAlbumViewHolder.CREATOR.viewType -> ArtistAlbumViewHolder.CREATOR + ArtistSongViewHolder.CREATOR.viewType -> ArtistSongViewHolder.CREATOR + else -> null + } + override fun onBind( + viewHolder: RecyclerView.ViewHolder, + item: Item, + listener: DetailItemListener + ) { + super.onBind(viewHolder, item, listener) when (item) { - is Artist -> (holder as ArtistDetailViewHolder).bind(item) - is Album -> (holder as ArtistAlbumViewHolder).bind(item) - is Song -> (holder as ArtistSongViewHolder).bind(item) - is Header -> (holder as HeaderViewHolder).bind(item) - is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) + is Artist -> (viewHolder as ArtistDetailViewHolder).bind(item, listener) + is Album -> (viewHolder as ArtistAlbumViewHolder).bind(item, listener) + is Song -> (viewHolder as ArtistSongViewHolder).bind(item, listener) else -> {} } + } - if (holder is Highlightable) { - // If the item corresponds to a currently playing song/album then highlight it - if (item.id == currentAlbum?.id && item is Album) { - currentAlbumHolder?.setHighlighted(false) - currentAlbumHolder = holder - holder.setHighlighted(true) - } else if (item.id == currentSong?.id && item is Song) { - currentSongHolder?.setHighlighted(false) - currentSongHolder = holder - holder.setHighlighted(true) - } else { - holder.setHighlighted(false) - } + override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) { + // If the item corresponds to a currently playing song/album then highlight it + if (item.id == currentAlbum?.id && item is Album) { + currentAlbumHolder?.setHighlighted(false) + currentAlbumHolder = viewHolder + viewHolder.setHighlighted(true) + } else if (item.id == currentSong?.id && item is Song) { + currentSongHolder?.setHighlighted(false) + currentSongHolder = viewHolder + viewHolder.setHighlighted(true) + } else { + viewHolder.setHighlighted(false) } } @@ -118,26 +104,10 @@ class ArtistDetailAdapter( * @param recycler The recyclerview the highlighting should act on. */ fun highlightAlbum(album: Album?, recycler: RecyclerView) { - if (album == currentAlbum) return // Already highlighting this ViewHolder - - // Album is no longer valid, clear out this ViewHolder. - currentAlbumHolder?.setHighlighted(false) - currentAlbumHolder = null + if (album == currentAlbum) return currentAlbum = album - - if (album != null) { - // Use existing data instead of having to re-sort it. - val pos = currentList.indexOfFirst { item -> item.id == album.id && item is Album } - - // Check if the ViewHolder if this album is visible, and highlight it if so. - recycler.layoutManager?.findViewByPosition(pos)?.let { child -> - recycler.getChildViewHolder(child)?.let { - currentAlbumHolder = it as Highlightable - - currentAlbumHolder?.setHighlighted(true) - } - } - } + currentAlbumHolder?.setHighlighted(false) + currentAlbumHolder = highlightItem(album, recycler) } /** @@ -145,91 +115,144 @@ class ArtistDetailAdapter( * @param recycler The recyclerview the highlighting should act on. */ fun highlightSong(song: Song?, recycler: RecyclerView) { - if (song == currentSong) return // Already highlighting this ViewHolder - - // Clear the current ViewHolder since it's invalid - currentSongHolder?.setHighlighted(false) - currentSongHolder = null + if (song == currentSong) return currentSong = song + currentSongHolder?.setHighlighted(false) + currentSongHolder = highlightItem(song, recycler) + } - if (song != null) { - // Use existing data instead of having to re-sort it. - // We also have to account for the album count when searching for the ViewHolder. - val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song } - - // Check if the ViewHolder for this song is visible, if it is then highlight it. - // If the ViewHolder is not visible, then the adapter should take care of it if - // it does become visible. - recycler.layoutManager?.findViewByPosition(pos)?.let { child -> - recycler.getChildViewHolder(child)?.let { - currentSongHolder = it as Highlightable - currentSongHolder?.setHighlighted(true) + companion object { + private val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + return when { + oldItem is Artist && newItem is Artist -> + ArtistDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + oldItem is Album && newItem is Album -> + ArtistAlbumViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + oldItem is Song && newItem is Song -> + ArtistSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem) + } } } - } - } - - inner class ArtistDetailViewHolder(private val binding: ItemDetailBinding) : - BaseViewHolder(binding) { - - override fun onBind(data: Artist) { - val context = binding.root.context - - binding.detailCover.apply { - bindArtistImage(data) - contentDescription = - context.getString(R.string.desc_artist_image, data.resolvedName) - } - - binding.detailName.textSafe = data.resolvedName - - // Get the genre that corresponds to the most songs in this artist, which would be - // the most "Prominent" genre. - binding.detailSubhead.textSafe = - data.songs - .groupBy { it.genre.resolvedName } - .entries - .maxByOrNull { it.value.size } - ?.key - ?: context.getString(R.string.def_genre) - - binding.detailInfo.textSafe = - binding.context.getString( - R.string.fmt_two, - binding.context.getPluralSafe(R.plurals.fmt_album_count, data.albums.size), - binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size)) - - binding.detailPlayButton.setOnClickListener { playbackModel.playArtist(data, false) } - - binding.detailShuffleButton.setOnClickListener { playbackModel.playArtist(data, true) } - } - } - - inner class ArtistAlbumViewHolder( - private val binding: ItemParentBinding, - ) : BaseViewHolder(binding, doOnClick, doOnLongClick), Highlightable { - override fun onBind(data: Album) { - binding.parentImage.bindAlbumCover(data) - binding.parentName.textSafe = data.resolvedName - binding.parentInfo.textSafe = binding.context.getString(R.string.fmt_number, data.year) - } - - override fun setHighlighted(isHighlighted: Boolean) { - binding.parentName.isActivated = isHighlighted - } - } - - inner class ArtistSongViewHolder( - private val binding: ItemSongBinding, - ) : BaseViewHolder(binding, doOnSongClick, doOnLongClick), Highlightable { - override fun onBind(data: Song) { - binding.songAlbumCover.bindAlbumCover(data) - binding.songName.textSafe = data.resolvedName - binding.songInfo.textSafe = data.resolvedAlbumName - } - - override fun setHighlighted(isHighlighted: Boolean) { - binding.songName.isActivated = isHighlighted - } + } +} + +private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) : + BindingViewHolder(binding.root) { + + override fun bind(item: Artist, listener: DetailItemListener) { + binding.detailCover.bindArtistImage(item) + binding.detailName.textSafe = item.resolvedName + + // Get the genre that corresponds to the most songs in this artist, which would be + // the most "Prominent" genre. + binding.detailSubhead.textSafe = + item.songs.groupBy { it.genre.resolvedName }.entries.maxByOrNull { it.value.size }?.key + ?: binding.context.getString(R.string.def_genre) + + binding.detailInfo.textSafe = + binding.context.getString( + R.string.fmt_two, + binding.context.getPluralSafe(R.plurals.fmt_album_count, item.albums.size), + binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size)) + + binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } + binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } + } + + companion object { + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_ARTIST_DETAIL + + override fun create(context: Context) = + ArtistDetailViewHolder(ItemDetailBinding.inflate(context.inflater)) + } + + val DIFFER = ArtistViewHolder.DIFFER + } +} + +private class ArtistAlbumViewHolder +private constructor( + private val binding: ItemParentBinding, +) : BindingViewHolder(binding.root), Highlightable { + override fun bind(item: Album, listener: MenuItemListener) { + binding.parentImage.bindAlbumCover(item) + binding.parentName.textSafe = item.resolvedName + binding.parentInfo.textSafe = binding.context.getString(R.string.fmt_number, item.year) + + binding.root.apply { + setOnClickListener { listener.onItemClick(item) } + setOnLongClickListener { view -> + listener.onOpenMenu(item, view) + true + } + } + } + + override fun setHighlighted(isHighlighted: Boolean) { + binding.parentName.isActivated = isHighlighted + } + + companion object { + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_ARTIST_ALBUM + + override fun create(context: Context) = + ArtistAlbumViewHolder(ItemParentBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Album, newItem: Album) = + oldItem.resolvedName == newItem.resolvedName && oldItem.year == newItem.year + } + } +} + +private class ArtistSongViewHolder +private constructor( + private val binding: ItemSongBinding, +) : BindingViewHolder(binding.root), Highlightable { + override fun bind(item: Song, listener: MenuItemListener) { + binding.songAlbumCover.bindAlbumCover(item) + binding.songName.textSafe = item.resolvedName + binding.songInfo.textSafe = item.resolvedAlbumName + + binding.root.apply { + setOnClickListener { listener.onItemClick(item) } + setOnLongClickListener { view -> + listener.onOpenMenu(item, view) + true + } + } + } + + override fun setHighlighted(isHighlighted: Boolean) { + binding.songName.isActivated = isHighlighted + } + + companion object { + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_ARTIST_SONG + + override fun create(context: Context) = + ArtistSongViewHolder(ItemSongBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Song, newItem: Song) = + oldItem.resolvedName == newItem.resolvedName && + oldItem.resolvedAlbumName == newItem.resolvedAlbumName + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt new file mode 100644 index 000000000..905610d0a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt @@ -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 . + */ + +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( + listener: L, + diffCallback: DiffUtil.ItemCallback +) : MultiAdapter(listener, diffCallback) { + abstract fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) + + protected inline fun 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() { + 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(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 { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_SORT_HEADER + + override fun create(context: Context) = + SortHeaderViewHolder(ItemSortHeaderBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + 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) +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt index 48e283882..d9902a5f7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt @@ -17,9 +17,7 @@ package org.oxycblt.auxio.detail.recycler -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter +import android.content.Context import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -27,70 +25,65 @@ import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.coil.bindGenreImage import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemSongBinding -import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ActionHeaderViewHolder -import org.oxycblt.auxio.ui.BaseViewHolder -import org.oxycblt.auxio.ui.DiffCallback +import org.oxycblt.auxio.ui.BindingViewHolder +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.ItemDiffCallback +import org.oxycblt.auxio.ui.MenuItemListener +import org.oxycblt.auxio.ui.SongViewHolder import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.textSafe /** - * An adapter for displaying the [Song]s of a genre. + * An adapter for displaying genre information and it's children. * @author OxygenCobalt */ -class GenreDetailAdapter( - private val playbackModel: PlaybackViewModel, - private val doOnClick: (data: Song) -> Unit, - private val doOnLongClick: (view: View, data: Song) -> Unit -) : ListAdapter(DiffCallback()) { +class GenreDetailAdapter(listener: DetailItemListener) : + DetailAdapter(listener, DIFFER) { private var currentSong: Song? = null private var currentHolder: Highlightable? = null - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is Genre -> IntegerTable.ITEM_TYPE_GENRE_DETAIL - is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER - is Song -> IntegerTable.ITEM_TYPE_GENRE_SONG - else -> -1 - } - } + override fun getCreatorFromItem(item: Item) = + super.getCreatorFromItem(item) + ?: when (item) { + is Genre -> GenreDetailViewHolder.CREATOR + is Song -> GenreSongViewHolder.CREATOR + else -> null + } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - IntegerTable.ITEM_TYPE_GENRE_DETAIL -> - GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) - IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context) - IntegerTable.ITEM_TYPE_GENRE_SONG -> - GenreSongViewHolder(ItemSongBinding.inflate(parent.context.inflater)) - else -> error("Bad ViewHolder item type $viewType") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - val item = getItem(position) + override fun getCreatorFromViewType(viewType: Int) = + super.getCreatorFromViewType(viewType) + ?: when (viewType) { + GenreDetailViewHolder.CREATOR.viewType -> GenreDetailViewHolder.CREATOR + GenreSongViewHolder.CREATOR.viewType -> GenreSongViewHolder.CREATOR + else -> null + } + override fun onBind( + viewHolder: RecyclerView.ViewHolder, + item: Item, + listener: DetailItemListener + ) { + super.onBind(viewHolder, item, listener) when (item) { - is Genre -> (holder as GenreDetailViewHolder).bind(item) - is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) - is Song -> (holder as GenreSongViewHolder).bind(item) + is Genre -> (viewHolder as GenreDetailViewHolder).bind(item, listener) + is Song -> (viewHolder as GenreSongViewHolder).bind(item, listener) else -> {} } + } - if (holder is Highlightable) { - if (item.id == currentSong?.id) { - // Reset the last ViewHolder before assigning the new, correct one to be highlighted - currentHolder?.setHighlighted(false) - currentHolder = holder - holder.setHighlighted(true) - } else { - holder.setHighlighted(false) - } + override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) { + // If the item corresponds to a currently playing song/album then highlight it + if (item.id == currentSong?.id) { + // Reset the last ViewHolder before assigning the new, correct one to be highlighted + currentHolder?.setHighlighted(false) + currentHolder = viewHolder + viewHolder.setHighlighted(true) + } else { + viewHolder.setHighlighted(false) } } @@ -99,64 +92,89 @@ class GenreDetailAdapter( * @param recycler The recyclerview the highlighting should act on. */ fun highlightSong(song: Song?, recycler: RecyclerView) { - if (song == currentSong) return // Already highlighting this ViewHolder - - // Clear the current ViewHolder since it's invalid - currentHolder?.setHighlighted(false) - currentHolder = null + if (song == currentSong) return currentSong = song + currentHolder?.setHighlighted(false) + currentHolder = highlightItem(song, recycler) + } - if (song != null) { - // Use existing data instead of having to re-sort it. - val pos = currentList.indexOfFirst { item -> item.id == song.id && item is Song } - - // Check if the ViewHolder for this song is visible, if it is then highlight it. - // If the ViewHolder is not visible, then the adapter should take care of it if - // it does become visible. - recycler.layoutManager?.findViewByPosition(pos)?.let { child -> - recycler.getChildViewHolder(child)?.let { - currentHolder = it as Highlightable - currentHolder?.setHighlighted(true) + companion object { + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + return when { + oldItem is Genre && newItem is Genre -> + GenreDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + oldItem is Song && newItem is Song -> + GenreSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem) + } } } - } - } - - inner class GenreDetailViewHolder(private val binding: ItemDetailBinding) : - BaseViewHolder(binding) { - override fun onBind(data: Genre) { - val context = binding.root.context - - binding.detailCover.apply { - bindGenreImage(data) - contentDescription = context.getString(R.string.desc_genre_image, data.resolvedName) - } - - binding.detailName.textSafe = data.resolvedName - binding.detailSubhead.textSafe = - binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size) - binding.detailInfo.textSafe = data.totalDuration - - binding.detailPlayButton.setOnClickListener { playbackModel.playGenre(data, false) } - - binding.detailShuffleButton.setOnClickListener { playbackModel.playGenre(data, true) } - } - } - - /** The Shared ViewHolder for a [Song]. Instantiation should be done with [from]. */ - inner class GenreSongViewHolder - constructor( - private val binding: ItemSongBinding, - ) : BaseViewHolder(binding, doOnClick, doOnLongClick), Highlightable { - - override fun onBind(data: Song) { - binding.songAlbumCover.bindAlbumCover(data) - binding.songName.textSafe = data.resolvedName - binding.songInfo.textSafe = data.resolvedArtistName - } - - override fun setHighlighted(isHighlighted: Boolean) { - binding.songName.isActivated = isHighlighted - } + } +} + +private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) : + BindingViewHolder(binding.root) { + override fun bind(item: Genre, listener: DetailItemListener) { + binding.detailCover.bindGenreImage(item) + binding.detailName.textSafe = item.resolvedName + binding.detailSubhead.textSafe = + binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size) + binding.detailInfo.textSafe = item.totalDuration + binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } + binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } + } + + companion object { + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_GENRE_DETAIL + + override fun create(context: Context) = + GenreDetailViewHolder(ItemDetailBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Genre, newItem: Genre) = + oldItem.resolvedName == newItem.resolvedName && + oldItem.songs.size == newItem.songs.size && + oldItem.totalDuration == newItem.totalDuration + } + } +} + +class GenreSongViewHolder private constructor(private val binding: ItemSongBinding) : + BindingViewHolder(binding.root), Highlightable { + override fun bind(item: Song, listener: MenuItemListener) { + binding.songAlbumCover.bindAlbumCover(item) + binding.songName.textSafe = item.resolvedName + binding.songInfo.textSafe = item.resolvedArtistName + binding.root.apply { + setOnClickListener { listener.onItemClick(item) } + setOnLongClickListener { view -> + listener.onOpenMenu(item, view) + true + } + } + } + + override fun setHighlighted(isHighlighted: Boolean) { + binding.songName.isActivated = isHighlighted + } + + companion object { + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_GENRE_SONG + + override fun create(context: Context) = + GenreSongViewHolder(ItemSongBinding.inflate(context.inflater)) + } + + val DIFFER = SongViewHolder.DIFFER } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/Highlightable.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/Highlightable.kt deleted file mode 100644 index d5b3aec2f..000000000 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/Highlightable.kt +++ /dev/null @@ -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 . - */ - -package org.oxycblt.auxio.detail.recycler - -/** Interface that allows the highlighting of certain ViewHolders */ -interface Highlightable { - fun setHighlighted(isHighlighted: Boolean) -} diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt index 69110800a..f491c83c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt @@ -136,24 +136,33 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr removeCallbacks(hideThumbRunnable) showScrollbar() showPopup() + onDragListener?.onFastScrollStart() } else { postAutoHideScrollbar() hidePopup() + onDragListener?.onFastScrollStop() } - - onDragListener?.invoke(value) } private val tRect = Rect() + interface PopupProvider { + fun getPopup(pos: Int): String? + } + /** Callback to provide a string to be shown on the popup when an item is passed */ - var popupProvider: ((Int) -> String)? = null + var popupProvider: PopupProvider? = null + + interface OnFastScrollListener { + fun onFastScrollStart() + fun onFastScrollStop() + } /** * A listener for when a drag event occurs. The value will be true if a drag has begun, and * false if a drag ended. */ - var onDragListener: ((Boolean) -> Unit)? = null + var onDragListener: OnFastScrollListener? = null init { overlay.add(thumbView) @@ -186,8 +195,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr // --- RECYCLERVIEW EVENT MANAGEMENT --- private fun onPreDraw() { - // FIXME: Make the way we lay out views less of a hacky mess. Perhaps consider - // overlaying views or turning this into a ViewGroup. updateScrollbarState() thumbView.layoutDirection = layoutDirection @@ -207,13 +214,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr val firstPos = firstAdapterPos val popupText = if (firstPos != NO_POSITION) { - popupProvider?.invoke(firstPos)?.ifEmpty { null } + popupProvider?.getPopup(firstPos)?.ifEmpty { null } } else { null } - // Lay out the popup view - popupView.isInvisible = popupText == null if (popupText != null) { @@ -370,6 +375,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } private fun scrollTo(offset: Int) { + if (childCount == 0) { + return + } + stopScroll() val trueOffset = offset - paddingTop diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index c779d29af..e198a3a15 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -17,16 +17,18 @@ package org.oxycblt.auxio.home.list -import android.os.Bundle import android.view.View -import android.view.ViewGroup +import androidx.lifecycle.LiveData import androidx.navigation.fragment.findNavController import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.ui.AlbumViewHolder +import org.oxycblt.auxio.ui.BindingViewHolder import org.oxycblt.auxio.ui.DisplayMode +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.MenuItemListener +import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.sliceArticle @@ -35,50 +37,43 @@ import org.oxycblt.auxio.ui.sliceArticle * A [HomeListFragment] for showing a list of [Album]s. * @author */ -class AlbumListFragment : HomeListFragment() { - override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { - val homeAdapter = - AlbumAdapter( - doOnClick = { album -> - findNavController().navigate(HomeFragmentDirections.actionShowAlbum(album.id)) - }, - ::newMenu) +class AlbumListFragment : HomeListFragment() { + override val recyclerId: Int = R.id.home_album_list + override val homeAdapter = AlbumAdapter(this) + override val homeData: LiveData> + get() = homeModel.albums - setupRecycler(R.id.home_album_list, homeAdapter, homeModel.albums) + override fun getPopup(pos: Int): String? { + val album = homeModel.albums.value!![pos] + + // Change how we display the popup depending on the mode. + return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) { + // By Name -> Use Name + is Sort.ByName -> album.resolvedName.sliceArticle().first().uppercase() + + // By Artist -> Use Artist Name + is Sort.ByArtist -> album.artist.resolvedName.sliceArticle().first().uppercase() + + // Year -> Use Full Year + is Sort.ByYear -> album.year?.toString() ?: getString(R.string.def_date) + + // Unsupported sort, error gracefully + else -> null + } } - override val listPopupProvider: (Int) -> String - get() = { idx -> - val album = homeModel.albums.value!![idx] + override fun onItemClick(item: Item) { + check(item is Album) + findNavController().navigate(HomeFragmentDirections.actionShowAlbum(item.id)) + } - // Change how we display the popup depending on the mode. - when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) { - // By Name -> Use Name - is Sort.ByName -> album.resolvedName.sliceArticle().first().uppercase() + override fun onOpenMenu(item: Item, anchor: View) { + newMenu(anchor, item) + } - // By Artist -> Use Artist Name - is Sort.ByArtist -> album.artist.resolvedName.sliceArticle().first().uppercase() - - // Year -> Use Full Year - is Sort.ByYear -> album.year?.toString() ?: getString(R.string.def_date) - - // Unsupported sort, error gracefully - else -> "" - } - } - - class AlbumAdapter( - private val doOnClick: (data: Album) -> Unit, - private val doOnLongClick: (view: View, data: Album) -> Unit, - ) : HomeAdapter() { - override fun getItemCount(): Int = data.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AlbumViewHolder { - return AlbumViewHolder.from(parent.context, doOnClick, doOnLongClick) - } - - override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) { - holder.bind(data[position]) - } + class AlbumAdapter(listener: MenuItemListener) : + MonoAdapter(listener, AlbumViewHolder.DIFFER) { + override val creator: BindingViewHolder.Creator + get() = AlbumViewHolder.CREATOR } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index 999f1193d..b85792049 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -17,15 +17,16 @@ package org.oxycblt.auxio.home.list -import android.os.Bundle import android.view.View -import android.view.ViewGroup +import androidx.lifecycle.LiveData import androidx.navigation.fragment.findNavController import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.ui.ArtistViewHolder +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.MenuItemListener +import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.sliceArticle @@ -33,35 +34,26 @@ import org.oxycblt.auxio.ui.sliceArticle * A [HomeListFragment] for showing a list of [Artist]s. * @author */ -class ArtistListFragment : HomeListFragment() { - override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { - val homeAdapter = - ArtistAdapter( - doOnClick = { artist -> - findNavController().navigate(HomeFragmentDirections.actionShowArtist(artist.id)) - }, - ::newMenu) +class ArtistListFragment : HomeListFragment() { + override val recyclerId: Int = R.id.home_artist_list + override val homeAdapter = ArtistAdapter(this) + override val homeData: LiveData> + get() = homeModel.artists - setupRecycler(R.id.home_artist_list, homeAdapter, homeModel.artists) + override fun getPopup(pos: Int) = + homeModel.artists.value!![pos].resolvedName.sliceArticle().first().uppercase() + + override fun onItemClick(item: Item) { + check(item is Artist) + findNavController().navigate(HomeFragmentDirections.actionShowArtist(item.id)) } - override val listPopupProvider: (Int) -> String - get() = { idx -> - homeModel.artists.value!![idx].resolvedName.sliceArticle().first().uppercase() - } + override fun onOpenMenu(item: Item, anchor: View) { + newMenu(anchor, item) + } - class ArtistAdapter( - private val doOnClick: (data: Artist) -> Unit, - private val doOnLongClick: (view: View, data: Artist) -> Unit, - ) : HomeAdapter() { - override fun getItemCount(): Int = data.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { - return ArtistViewHolder.from(parent.context, doOnClick, doOnLongClick) - } - - override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { - holder.bind(data[position]) - } + class ArtistAdapter(listener: MenuItemListener) : + MonoAdapter(listener, ArtistViewHolder.DIFFER) { + override val creator = ArtistViewHolder.CREATOR } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index e3f2684ea..5aaf718e6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -17,52 +17,43 @@ package org.oxycblt.auxio.home.list -import android.os.Bundle import android.view.View -import android.view.ViewGroup +import androidx.lifecycle.LiveData import androidx.navigation.fragment.findNavController import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.ui.GenreViewHolder +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.MenuItemListener +import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.sliceArticle -import org.oxycblt.auxio.util.context /** * A [HomeListFragment] for showing a list of [Genre]s. * @author */ -class GenreListFragment : HomeListFragment() { - override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { - val homeAdapter = - GenreAdapter( - doOnClick = { Genre -> - findNavController().navigate(HomeFragmentDirections.actionShowGenre(Genre.id)) - }, - ::newMenu) +class GenreListFragment : HomeListFragment() { + override val recyclerId = R.id.home_genre_list + override val homeAdapter = GenreAdapter(this) + override val homeData: LiveData> + get() = homeModel.genres - setupRecycler(R.id.home_genre_list, homeAdapter, homeModel.genres) + override fun getPopup(pos: Int) = + homeModel.genres.value!![pos].resolvedName.sliceArticle().first().uppercase() + + override fun onItemClick(item: Item) { + check(item is Genre) + findNavController().navigate(HomeFragmentDirections.actionShowGenre(item.id)) } - override val listPopupProvider: (Int) -> String - get() = { idx -> - homeModel.genres.value!![idx].resolvedName.sliceArticle().first().uppercase() - } + override fun onOpenMenu(item: Item, anchor: View) { + newMenu(anchor, item) + } - class GenreAdapter( - private val doOnClick: (data: Genre) -> Unit, - private val doOnLongClick: (view: View, data: Genre) -> Unit, - ) : HomeAdapter() { - override fun getItemCount(): Int = data.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GenreViewHolder { - return GenreViewHolder.from(parent.context, doOnClick, doOnLongClick) - } - - override fun onBindViewHolder(holder: GenreViewHolder, position: Int) { - holder.bind(data[position]) - } + class GenreAdapter(listener: MenuItemListener) : + MonoAdapter(listener, GenreViewHolder.DIFFER) { + override val creator = GenreViewHolder.CREATOR } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt index 0d24dfc71..0e3851152 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt @@ -17,17 +17,19 @@ package org.oxycblt.auxio.home.list -import android.annotation.SuppressLint +import android.os.Bundle import android.view.LayoutInflater -import androidx.annotation.IdRes import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.LiveData -import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel -import org.oxycblt.auxio.music.Item +import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.BindingViewHolder +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.MenuItemListener +import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.applySpans @@ -35,49 +37,52 @@ import org.oxycblt.auxio.util.applySpans * A Base [Fragment] implementing the base features shared across all list fragments in the home UI. * @author OxygenCobalt */ -abstract class HomeListFragment : ViewBindingFragment() { +abstract class HomeListFragment : + ViewBindingFragment(), + MenuItemListener, + FastScrollRecyclerView.PopupProvider, + FastScrollRecyclerView.OnFastScrollListener { /** The popup provider to use for the fast scroller view. */ - abstract val listPopupProvider: (Int) -> String + abstract val recyclerId: Int + abstract val homeAdapter: + MonoAdapter> + abstract val homeData: LiveData> protected val homeModel: HomeViewModel by activityViewModels() protected val playbackModel: PlaybackViewModel by activityViewModels() - protected fun setupRecycler( - @IdRes uniqueId: Int, - homeAdapter: HomeAdapter, - homeData: LiveData>, - ) { - requireBinding().homeRecycler.apply { - id = uniqueId - adapter = homeAdapter - setHasFixedSize(true) - applySpans() - - popupProvider = listPopupProvider - onDragListener = homeModel::updateFastScrolling - } - - homeData.observe(viewLifecycleOwner, homeAdapter::updateData) - } - override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeListBinding.inflate(inflater) - override fun onDestroyBinding(binding: FragmentHomeListBinding) { - homeModel.updateFastScrolling(false) - } + override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { + binding.homeRecycler.apply { + id = recyclerId + adapter = homeAdapter + applySpans() + } - abstract class HomeAdapter : - RecyclerView.Adapter() { - protected var data = listOf() + binding.homeRecycler.popupProvider = this + binding.homeRecycler.onDragListener = this - @SuppressLint("NotifyDataSetChanged") - fun updateData(newData: List) { - data = newData - - // notifyDataSetChanged here is okay, as we have no idea how the layout changed when - // we re-sort and ListAdapter causes the scroll position to get messed up - notifyDataSetChanged() + homeData.observe(viewLifecycleOwner) { list -> + homeAdapter.submitListHard(list.toMutableList()) } } + + override fun onDestroyBinding(binding: FragmentHomeListBinding) { + homeModel.updateFastScrolling(false) + binding.homeRecycler.apply { + adapter = null + popupProvider = null + onDragListener = null + } + } + + override fun onFastScrollStart() { + homeModel.updateFastScrolling(true) + } + + override fun onFastScrollStop() { + homeModel.updateFastScrolling(false) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 745dd880e..78099d53c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -17,13 +17,14 @@ package org.oxycblt.auxio.home.list -import android.os.Bundle import android.view.View -import android.view.ViewGroup +import androidx.lifecycle.LiveData import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.ui.DisplayMode +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.MenuItemListener +import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.SongViewHolder import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.newMenu @@ -33,47 +34,44 @@ import org.oxycblt.auxio.ui.sliceArticle * A [HomeListFragment] for showing a list of [Song]s. * @author */ -class SongListFragment : HomeListFragment() { - override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { - val homeAdapter = SongsAdapter(doOnClick = playbackModel::playSong, ::newMenu) - setupRecycler(R.id.home_song_list, homeAdapter, homeModel.songs) +class SongListFragment : HomeListFragment() { + override val recyclerId = R.id.home_song_list + override val homeAdapter = SongsAdapter(this) + override val homeData: LiveData> + get() = homeModel.songs + + override fun getPopup(pos: Int): String { + val song = homeModel.songs.value!![pos] + + // Change how we display the popup depending on the mode. + // We don't use the more correct resolve(Model)Name here, as sorts are largely + // based off the names of the parent objects and not the child objects. + return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) { + // Name -> Use name + is Sort.ByName -> song.resolvedName.sliceArticle().first().uppercase() + + // Artist -> Use Artist Name + is Sort.ByArtist -> song.album.artist.resolvedName.sliceArticle().first().uppercase() + + // Album -> Use Album Name + is Sort.ByAlbum -> song.album.resolvedName.sliceArticle().first().uppercase() + + // Year -> Use Full Year + is Sort.ByYear -> song.album.year?.toString() ?: getString(R.string.def_date) + } } - override val listPopupProvider: (Int) -> String - get() = { idx -> - val song = homeModel.songs.value!![idx] + override fun onItemClick(item: Item) { + check(item is Song) + playbackModel.playSong(item) + } - // Change how we display the popup depending on the mode. - // We don't use the more correct resolve(Model)Name here, as sorts are largely - // based off the names of the parent objects and not the child objects. - when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) { - // Name -> Use name - is Sort.ByName -> song.resolvedName.sliceArticle().first().uppercase() + override fun onOpenMenu(item: Item, anchor: View) { + newMenu(anchor, item) + } - // Artist -> Use Artist Name - is Sort.ByArtist -> - song.album.artist.resolvedName.sliceArticle().first().uppercase() - - // Album -> Use Album Name - is Sort.ByAlbum -> song.album.resolvedName.sliceArticle().first().uppercase() - - // Year -> Use Full Year - is Sort.ByYear -> song.album.year?.toString() ?: getString(R.string.def_date) - } - } - - inner class SongsAdapter( - private val doOnClick: (data: Song) -> Unit, - private val doOnLongClick: (view: View, data: Song) -> Unit, - ) : HomeAdapter() { - override fun getItemCount(): Int = data.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder { - return SongViewHolder.from(parent.context, doOnClick, doOnLongClick) - } - - override fun onBindViewHolder(holder: SongViewHolder, position: Int) { - holder.bind(data[position]) - } + inner class SongsAdapter(listener: MenuItemListener) : + MonoAdapter(listener, SongViewHolder.DIFFER) { + override val creator = SongViewHolder.CREATOR } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Models.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt similarity index 82% rename from app/src/main/java/org/oxycblt/auxio/music/Models.kt rename to app/src/main/java/org/oxycblt/auxio/music/Music.kt index 1b7364ede..03064b22a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Models.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -20,18 +20,10 @@ package org.oxycblt.auxio.music import android.content.ContentUris import android.net.Uri import android.provider.MediaStore -import android.view.View -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes +import org.oxycblt.auxio.ui.Item // --- MUSIC MODELS --- -/** The base for all items in Auxio. */ -sealed class Item { - /** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */ - abstract val id: Long -} - /** [Item] variant that represents a music item. */ sealed class Music : Item() { /** The raw name of this item. */ @@ -245,49 +237,3 @@ data class Genre( val totalDuration: String get() = songs.sumOf { it.seconds }.toDuration(false) } - -/** A data object used solely for the "Header" UI element. */ -data class Header( - override val id: Long, - /** The string resource used for the header. */ - @StringRes val string: Int -) : Item() - -/** - * A data object used for an action header. Like [Header], but with a button. - * @see Header - */ -data class ActionHeader( - override val id: Long, - /** The string resource used for the header. */ - @StringRes val string: Int, - /** The icon resource used for the header action. */ - @DrawableRes val icon: Int, - /** The string resource used for the header action's content description. */ - @StringRes val desc: Int, - /** A callback for when this item is clicked. */ - val onClick: (View) -> Unit, -) : Item() { - // All lambdas are not equal to each-other, so we override equals/hashCode and exclude them. - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ActionHeader) return false - - if (id != other.id) return false - if (string != other.string) return false - if (icon != other.icon) return false - if (desc != other.desc) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + string.hashCode() - result = 31 * result + icon - result = 31 * result + desc - - return result - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt index fd54f6136..23bb9faa2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt @@ -357,10 +357,8 @@ class MusicLoader { while (cursor.moveToNext()) { // Genre names can be a normal name, an ID3v2 constant, or null. Normal names - // are - // resolved as usual, but null values don't make sense and are often junk - // anyway, - // so we skip genres that have them. + // are resolved as usual, but null values don't make sense and are often junk + // anyway, so we skip genres that have them. val id = cursor.getLong(idIndex) val name = cursor.getStringOrNull(nameIndex) ?: continue val resolvedName = name.genreNameCompat ?: name diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 0f0055066..72fa3e8eb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -18,137 +18,89 @@ package org.oxycblt.auxio.playback.queue import android.annotation.SuppressLint +import android.content.Context import android.graphics.drawable.ColorDrawable import android.view.MotionEvent import android.view.View -import android.view.ViewGroup import androidx.core.view.isInvisible -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.databinding.ItemQueueSongBinding -import org.oxycblt.auxio.music.ActionHeader -import org.oxycblt.auxio.music.Header -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.ui.ActionHeaderViewHolder -import org.oxycblt.auxio.ui.BaseViewHolder -import org.oxycblt.auxio.ui.DiffCallback -import org.oxycblt.auxio.ui.HeaderViewHolder +import org.oxycblt.auxio.ui.BindingViewHolder +import org.oxycblt.auxio.ui.MonoAdapter +import org.oxycblt.auxio.ui.SongViewHolder import org.oxycblt.auxio.util.disableDropShadowCompat import org.oxycblt.auxio.util.inflater -import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.stateList import org.oxycblt.auxio.util.textSafe -/** - * The single adapter for both the Next Queue and the User Queue. - * @param touchHelper The [ItemTouchHelper] ***containing*** [QueueDragCallback] to be used - * @author OxygenCobalt - */ -class QueueAdapter(private val touchHelper: ItemTouchHelper) : - RecyclerView.Adapter() { - private var data = mutableListOf() - private var listDiffer = AsyncListDiffer(this, DiffCallback()) +class NewQueueAdapter(listener: QueueItemListener) : + MonoAdapter( + listener, QueueSongViewHolder.DIFFER) { + override val creator = QueueSongViewHolder.CREATOR +} - override fun getItemCount(): Int = data.size +interface QueueItemListener { + fun onPickUp(viewHolder: RecyclerView.ViewHolder) +} - override fun getItemViewType(position: Int): Int { - return when (data[position]) { - is Song -> IntegerTable.ITEM_TYPE_QUEUE_SONG - is Header -> IntegerTable.ITEM_TYPE_HEADER - is ActionHeader -> IntegerTable.ITEM_TYPE_ACTION_HEADER - else -> -1 - } - } +class QueueSongViewHolder +private constructor( + private val binding: ItemQueueSongBinding, +) : BindingViewHolder(binding.root) { + val bodyView: View + get() = binding.body + val backgroundView: View + get() = binding.background - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - IntegerTable.ITEM_TYPE_QUEUE_SONG -> - QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater)) - IntegerTable.ITEM_TYPE_HEADER -> HeaderViewHolder.from(parent.context) - IntegerTable.ITEM_TYPE_ACTION_HEADER -> ActionHeaderViewHolder.from(parent.context) - else -> error("Invalid ViewHolder item type $viewType") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = data[position]) { - is Song -> (holder as QueueSongViewHolder).bind(item) - is Header -> (holder as HeaderViewHolder).bind(item) - is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item) - else -> logE("Bad data given to QueueAdapter") - } - } - - /** - * Submit data using [AsyncListDiffer]. **Only use this if you have no idea what changes - * occurred to the data** - */ - fun submitList(newData: MutableList) { - if (data != newData) { - data = newData - listDiffer.submitList(newData) - } - } - - /** Move Items. Used since [submitList] will cause QueueAdapter to freak out. */ - fun moveItems(adapterFrom: Int, adapterTo: Int) { - data.add(adapterTo, data.removeAt(adapterFrom)) - notifyItemMoved(adapterFrom, adapterTo) - } - - /** Remove an item. Used since [submitList] will cause QueueAdapter to freak out. */ - fun removeItem(adapterIndex: Int) { - data.removeAt(adapterIndex) - notifyItemRemoved(adapterIndex) - } - - /** Generic ViewHolder for a queue song */ - inner class QueueSongViewHolder( - private val binding: ItemQueueSongBinding, - ) : BaseViewHolder(binding) { - val bodyView: View - get() = binding.body - val backgroundView: View - get() = binding.background - - init { - binding.body.background = - MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { - fillColor = (binding.body.background as ColorDrawable).color.stateList - } - - binding.root.disableDropShadowCompat() - } - - @SuppressLint("ClickableViewAccessibility") - override fun onBind(data: Song) { - binding.songAlbumCover.bindAlbumCover(data) - binding.songName.textSafe = data.resolvedName - binding.songInfo.textSafe = data.resolvedArtistName - - binding.background.isInvisible = true - - binding.songName.requestLayout() - binding.songInfo.requestLayout() - - // Roll our own drag handlers as the default ones suck - binding.songDragHandle.setOnTouchListener { _, motionEvent -> - binding.songDragHandle.performClick() - if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) { - touchHelper.startDrag(this) - true - } else false + init { + binding.body.background = + MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { + fillColor = (binding.body.background as ColorDrawable).color.stateList } - binding.body.setOnLongClickListener { - touchHelper.startDrag(this) + binding.root.disableDropShadowCompat() + } + + @SuppressLint("ClickableViewAccessibility") + override fun bind(item: Song, listener: QueueItemListener) { + binding.songAlbumCover.bindAlbumCover(item) + binding.songName.textSafe = item.resolvedName + binding.songInfo.textSafe = item.resolvedArtistName + + binding.background.isInvisible = true + + binding.songName.requestLayout() + binding.songInfo.requestLayout() + + // Roll our own drag handlers as the default ones suck + binding.songDragHandle.setOnTouchListener { _, motionEvent -> + binding.songDragHandle.performClick() + if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) { + listener.onPickUp(this) true - } + } else false } + + binding.body.setOnLongClickListener { + listener.onPickUp(this) + true + } + } + + companion object { + val CREATOR = + object : Creator { + 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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt index 3096b2ab8..eed7a70a9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt @@ -38,8 +38,10 @@ import org.oxycblt.auxio.util.logD * hot garbage. This shouldn't have *too many* UI bugs. I hope. * @author OxygenCobalt */ -class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() { - private lateinit var queueAdapter: QueueAdapter +class QueueDragCallback( + private val playbackModel: PlaybackViewModel, + private val queueAdapter: NewQueueAdapter +) : ItemTouchHelper.Callback() { private var shouldLift = true override fun getMovementFlags( @@ -83,7 +85,7 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc // themselves when being dragged. Too bad google's implementation of this doesn't even // work! To emulate it on my own, I check if this child is in a drag state and then animate // an elevation change. - val holder = viewHolder as QueueAdapter.QueueSongViewHolder + val holder = viewHolder as QueueSongViewHolder if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { logD("Lifting queue item") @@ -122,7 +124,7 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { // When an elevated item is cleared, we reset the elevation using another animation. - val holder = viewHolder as QueueAdapter.QueueSongViewHolder + val holder = viewHolder as QueueSongViewHolder if (holder.itemView.translationZ != 0f) { logD("Dropping queue item") @@ -163,14 +165,6 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc override fun isLongPressDragEnabled(): Boolean = false - /** - * Add the queue adapter to this callback. Done because there's a circular dependency between - * the two objects - */ - fun addQueueAdapter(adapter: QueueAdapter) { - queueAdapter = adapter - } - companion object { const val MINIMUM_INITIAL_DRAG_VELOCITY = 10 const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25 diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 6a8ced7f4..258401422 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -23,56 +23,68 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.FragmentQueueBinding +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.requireAttached /** * A [Fragment] that shows the queue and enables editing as well. * @author OxygenCobalt */ -class QueueFragment : ViewBindingFragment() { +class QueueFragment : ViewBindingFragment(), QueueItemListener { private val playbackModel: PlaybackViewModel by activityViewModels() - private var lastShuffle: Boolean? = null + private var queueAdapter = NewQueueAdapter(this) + private var touchHelper: ItemTouchHelper? = null + private var callback: QueueDragCallback? = null override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentQueueBinding, savedInstanceState: Bundle?) { - // TODO: Merge ItemTouchHelper with QueueAdapter - val callback = QueueDragCallback(playbackModel) - val helper = ItemTouchHelper(callback) - val queueAdapter = QueueAdapter(helper) - callback.addQueueAdapter(queueAdapter) - binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() } binding.queueRecycler.apply { - setHasFixedSize(true) adapter = queueAdapter - helper.attachToRecyclerView(this) + requireTouchHelper().attachToRecyclerView(this) } // --- VIEWMODEL SETUP ---- - lastShuffle = playbackModel.isShuffling.value - playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling -> - // Try to prevent the queue adapter from going spastic during reshuffle events - // by just scrolling back to the top. - if (isShuffling != lastShuffle) { - logD("Reshuffle event, scrolling to top") - lastShuffle = isShuffling - binding.queueRecycler.scrollToPosition(0) - } + playbackModel.nextUp.observe(viewLifecycleOwner, ::updateQueue) + } + + override fun onDestroyBinding(binding: FragmentQueueBinding) { + super.onDestroyBinding(binding) + binding.queueRecycler.adapter = null + touchHelper = null + callback = null + } + + override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { + requireTouchHelper().startDrag(viewHolder) + } + + private fun updateQueue(queue: List) { + if (queue.isEmpty()) { + findNavController().navigateUp() + return } - playbackModel.nextUp.observe(viewLifecycleOwner) { queue -> - if (queue.isEmpty()) { - findNavController().navigateUp() - return@observe - } + queueAdapter.submitList(queue.toMutableList()) + } - queueAdapter.submitList(queue.toMutableList()) + private fun requireTouchHelper(): ItemTouchHelper { + requireAttached() + val instance = touchHelper + if (instance != null) { + return instance } + val newCallback = QueueDragCallback(playbackModel, queueAdapter) + val newInstance = ItemTouchHelper(newCallback) + callback = newCallback + touchHelper = newInstance + return newInstance } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index b57a43e02..815960429 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -17,68 +17,76 @@ package org.oxycblt.auxio.search -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Header -import org.oxycblt.auxio.music.Item -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.ui.AlbumViewHolder import org.oxycblt.auxio.ui.ArtistViewHolder -import org.oxycblt.auxio.ui.DiffCallback import org.oxycblt.auxio.ui.GenreViewHolder -import org.oxycblt.auxio.ui.HeaderViewHolder +import org.oxycblt.auxio.ui.Header +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.ItemDiffCallback +import org.oxycblt.auxio.ui.MenuItemListener +import org.oxycblt.auxio.ui.MultiAdapter +import org.oxycblt.auxio.ui.NewHeaderViewHolder import org.oxycblt.auxio.ui.SongViewHolder -/** - * A Multi-ViewHolder adapter that displays the results of a search query. - * @author OxygenCobalt - */ -class SearchAdapter( - private val doOnClick: (data: Music) -> Unit, - private val doOnLongClick: (view: View, data: Music) -> Unit -) : ListAdapter(DiffCallback()) { - - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is Genre -> IntegerTable.ITEM_TYPE_GENRE - is Artist -> IntegerTable.ITEM_TYPE_ARTIST - is Album -> IntegerTable.ITEM_TYPE_ALBUM - is Song -> IntegerTable.ITEM_TYPE_SONG - is Header -> IntegerTable.ITEM_TYPE_HEADER - else -> -1 +class NeoSearchAdapter(listener: MenuItemListener) : + MultiAdapter(listener, DIFFER) { + override fun getCreatorFromItem(item: Item) = + when (item) { + is Song -> SongViewHolder.CREATOR + is Album -> AlbumViewHolder.CREATOR + is Artist -> ArtistViewHolder.CREATOR + is Genre -> GenreViewHolder.CREATOR + is Header -> NewHeaderViewHolder.CREATOR + else -> null } - } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - IntegerTable.ITEM_TYPE_GENRE -> - GenreViewHolder.from(parent.context, doOnClick, doOnLongClick) - IntegerTable.ITEM_TYPE_ARTIST -> - ArtistViewHolder.from(parent.context, doOnClick, doOnLongClick) - IntegerTable.ITEM_TYPE_ALBUM -> - AlbumViewHolder.from(parent.context, doOnClick, doOnLongClick) - IntegerTable.ITEM_TYPE_SONG -> - SongViewHolder.from(parent.context, doOnClick, doOnLongClick) - IntegerTable.ITEM_TYPE_HEADER -> HeaderViewHolder.from(parent.context) - else -> error("Invalid ViewHolder item type") + override fun getCreatorFromViewType(viewType: Int) = + when (viewType) { + SongViewHolder.CREATOR.viewType -> SongViewHolder.CREATOR + AlbumViewHolder.CREATOR.viewType -> AlbumViewHolder.CREATOR + ArtistViewHolder.CREATOR.viewType -> ArtistViewHolder.CREATOR + GenreViewHolder.CREATOR.viewType -> GenreViewHolder.CREATOR + NewHeaderViewHolder.CREATOR.viewType -> NewHeaderViewHolder.CREATOR + else -> null } - } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = getItem(position)) { - is Genre -> (holder as GenreViewHolder).bind(item) - is Artist -> (holder as ArtistViewHolder).bind(item) - is Album -> (holder as AlbumViewHolder).bind(item) - is Song -> (holder as SongViewHolder).bind(item) - is Header -> (holder as HeaderViewHolder).bind(item) + override fun onBind( + viewHolder: RecyclerView.ViewHolder, + item: Item, + listener: MenuItemListener + ) { + when (item) { + is Song -> (viewHolder as SongViewHolder).bind(item, listener) + is Album -> (viewHolder as AlbumViewHolder).bind(item, listener) + is Artist -> (viewHolder as ArtistViewHolder).bind(item, listener) + is Genre -> (viewHolder as GenreViewHolder).bind(item, listener) + is Header -> (viewHolder as NewHeaderViewHolder).bind(item, Unit) else -> {} } } + + companion object { + private val DIFFER = + object : ItemDiffCallback() { + 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 + } + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 15887b1c4..dd21cb001 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.search import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.inputmethod.InputMethodManager import androidx.core.view.isInvisible import androidx.core.view.postDelayed @@ -33,44 +34,42 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Header -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.Header +import org.oxycblt.auxio.ui.Item +import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.getSystemServiceSafe import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.requireAttached /** * A [Fragment] that allows for the searching of the entire music library. * @author OxygenCobalt */ -class SearchFragment : ViewBindingFragment() { +class SearchFragment : ViewBindingFragment(), MenuItemListener { // SearchViewModel is only scoped to this Fragment private val searchModel: SearchViewModel by viewModels() private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() + private val searchAdapter = NeoSearchAdapter(this) + private var imm: InputMethodManager? = null private var launchedKeyboard = false override fun onCreateBinding(inflater: LayoutInflater) = FragmentSearchBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) { - val imm = requireContext().getSystemServiceSafe(InputMethodManager::class) - - val searchAdapter = - SearchAdapter(doOnClick = { item -> onItemSelection(item, imm) }, ::newMenu) - - // --- UI SETUP -- - binding.searchToolbar.apply { menu.findItem(searchModel.filterMode?.itemId ?: R.id.option_filter_all).isChecked = true setNavigationOnClickListener { - imm.hide() + requireImm().hide() findNavController().navigateUp() } @@ -94,7 +93,9 @@ class SearchFragment : ViewBindingFragment() { if (!launchedKeyboard) { // Auto-open the keyboard when this view is shown requestFocus() - postDelayed(200) { imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } + postDelayed(200) { + requireImm().showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } launchedKeyboard = true } @@ -107,13 +108,11 @@ class SearchFragment : ViewBindingFragment() { // --- VIEWMODEL SETUP --- - searchModel.searchResults.observe(viewLifecycleOwner) { results -> - updateResults(results, searchAdapter) - } + searchModel.searchResults.observe(viewLifecycleOwner, ::updateResults) detailModel.navToItem.observe(viewLifecycleOwner) { item -> handleNavigation(item) - imm.hide() + requireImm().hide() } } @@ -122,10 +121,47 @@ class SearchFragment : ViewBindingFragment() { searchModel.setNavigating(false) } - private fun updateResults(results: List, 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) { + if (isDetached) { + error("Fragment not attached to activity") + } + val binding = requireBinding() - searchAdapter.submitList(results) { + searchAdapter.submitList(results.toMutableList()) { // I would make it so that the position is only scrolled back to the top when // the query actually changes instead of once every re-creation event, but sadly // that doesn't seem possible. @@ -146,41 +182,18 @@ class SearchFragment : ViewBindingFragment() { }) } + private fun requireImm(): InputMethodManager { + requireAttached() + val instance = imm + if (instance != null) { + return instance + } + val newInstance = requireContext().getSystemServiceSafe(InputMethodManager::class) + imm = newInstance + return newInstance + } + private fun InputMethodManager.hide() { hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS) } - - /** - * Function that handles when an [item] is selected. Handles all datatypes that are selectable. - */ - private fun onItemSelection(item: Music, imm: InputMethodManager) { - if (item is Song) { - playbackModel.playSong(item) - - return - } - - if (!searchModel.isNavigating) { - searchModel.setNavigating(true) - - logD("Navigating to the detail fragment for ${item.rawName}") - - findNavController() - .navigate( - when (item) { - is Genre -> SearchFragmentDirections.actionShowGenre(item.id) - is Artist -> SearchFragmentDirections.actionShowArtist(item.id) - is Album -> SearchFragmentDirections.actionShowAlbum(item.id) - - // If given model wasn't valid, then reset the navigation status - // and abort the navigation. - else -> { - searchModel.setNavigating(false) - return - } - }) - - imm.hide() - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index a5409ed29..b83051c82 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -25,12 +25,12 @@ import androidx.lifecycle.viewModelScope import java.text.Normalizer import kotlinx.coroutines.launch import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Header -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.ui.DisplayMode +import org.oxycblt.auxio.ui.Header +import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.util.logD diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt b/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt index fd0780274..510e894b7 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt @@ -30,7 +30,6 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.showToast diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt b/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt deleted file mode 100644 index 3317373f2..000000000 --- a/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt +++ /dev/null @@ -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 . - */ - -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 : DiffUtil.ItemCallback() { - 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() - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/EdgeRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/ui/EdgeRecyclerView.kt index 9a1d22079..4a135b7b8 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/EdgeRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/EdgeRecyclerView.kt @@ -38,8 +38,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr clipToPadding = false } - override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + override fun onAttachedToWindow() { + super.onAttachedToWindow() + setHasFixedSize(true) + } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { updatePadding( initialPadding.left, initialPadding.top, diff --git a/app/src/main/java/org/oxycblt/auxio/ui/RecyclerFramework.kt b/app/src/main/java/org/oxycblt/auxio/ui/RecyclerFramework.kt new file mode 100644 index 000000000..9b775b555 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/RecyclerFramework.kt @@ -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 . + */ + +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( + diffCallback: DiffUtil.ItemCallback +) : RecyclerView.Adapter() { + protected var mCurrentList = mutableListOf() + val currentList: List + 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, onDone: () -> Unit = {}) { + if (newData != mCurrentList) { + mCurrentList = newData.toMutableList() + differ.submitList(newData, onDone) + } + } + + @Suppress("NotifyDatasetChanged") + fun submitListHard(newList: List) { + 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 AsyncListDiffer.rewriteListUnsafe(newList: List) { + 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>( + private val listener: L, + diffCallback: DiffUtil.ItemCallback +) : HybridAdapter(diffCallback) { + protected abstract val creator: BindingViewHolder.Creator + + 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(private val listener: L, diffCallback: DiffUtil.ItemCallback) : + HybridAdapter(diffCallback) { + abstract fun getCreatorFromItem( + item: Item + ): BindingViewHolder.Creator? + abstract fun getCreatorFromViewType( + viewType: Int + ): BindingViewHolder.Creator? + 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 : DiffUtil.ItemCallback() { + 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(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 { + val viewType: Int + fun create(context: Context): VH + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt index 64ba83879..52484ac1d 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 Auxio Project + * Copyright (c) 2022 Auxio Project * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,99 +18,54 @@ package org.oxycblt.auxio.ui import android.content.Context -import android.view.View -import androidx.appcompat.widget.TooltipCompat -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding +import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.coil.bindArtistImage import org.oxycblt.auxio.coil.bindGenreImage -import org.oxycblt.auxio.databinding.ItemActionHeaderBinding import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding -import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Header -import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.textSafe -/** - * A [RecyclerView.ViewHolder] that streamlines a lot of the common things across all viewholders. - * @param T The datatype, inheriting [Item] for this ViewHolder. - * @param binding Basic [ViewDataBinding] required to set up click listeners & sizing. - * @param doOnClick (Optional) Function that calls on a click. - * @param doOnLongClick (Optional) Functions that calls on a long-click. - * @author OxygenCobalt - */ -abstract class BaseViewHolder( - private val binding: ViewBinding, - private val doOnClick: ((data: T) -> Unit)? = null, - private val doOnLongClick: ((view: View, data: T) -> Unit)? = null -) : RecyclerView.ViewHolder(binding.root) { - init { - // Force the layout to *actually* be the screen width - binding.root.layoutParams = - RecyclerView.LayoutParams( - RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) - } - - /** - * Bind the viewholder with whatever [Item] instance that has been specified. Will call [onBind] - * on the inheriting ViewHolder. - * @param data Data that the viewholder should be bound with - */ - fun bind(data: T) { - doOnClick?.let { onClick -> binding.root.setOnClickListener { onClick(data) } } - - doOnLongClick?.let { onLongClick -> - binding.root.setOnLongClickListener { view -> - onLongClick(view, data) +class SongViewHolder private constructor(private val binding: ItemSongBinding) : + BindingViewHolder(binding.root) { + override fun bind(item: Song, listener: MenuItemListener) { + binding.songAlbumCover.bindAlbumCover(item) + binding.songName.textSafe = item.resolvedName + binding.songInfo.textSafe = item.resolvedArtistName + binding.root.apply { + setOnClickListener { listener.onItemClick(item) } + setOnLongClickListener { view -> + listener.onOpenMenu(item, view) true } } - - onBind(data) - } - - /** - * Function that performs binding operations unique to the inheriting viewholder. Add any - * specialized code to an override of this instead of [BaseViewHolder] itself. - */ - protected abstract fun onBind(data: T) -} - -/** The Shared ViewHolder for a [Song]. Instantiation should be done with [from]. */ -class SongViewHolder -private constructor( - private val binding: ItemSongBinding, - doOnClick: (data: Song) -> Unit, - doOnLongClick: (view: View, data: Song) -> Unit -) : BaseViewHolder(binding, doOnClick, doOnLongClick) { - - override fun onBind(data: Song) { - binding.songAlbumCover.bindAlbumCover(data) - binding.songName.textSafe = data.resolvedName - binding.songInfo.textSafe = data.resolvedArtistName } companion object { - /** Create an instance of [SongViewHolder] */ - fun from( - context: Context, - doOnClick: (data: Song) -> Unit, - doOnLongClick: (view: View, data: Song) -> Unit - ): SongViewHolder { - return SongViewHolder( - ItemSongBinding.inflate(context.inflater), doOnClick, doOnLongClick) - } + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_SONG + + override fun create(context: Context) = + SongViewHolder(ItemSongBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Song, newItem: Song) = + oldItem.resolvedName == newItem.resolvedName && + oldItem.resolvedArtistName == oldItem.resolvedArtistName + } } } @@ -118,122 +73,142 @@ private constructor( class AlbumViewHolder private constructor( private val binding: ItemParentBinding, - doOnClick: (data: Album) -> Unit, - doOnLongClick: (view: View, data: Album) -> Unit -) : BaseViewHolder(binding, doOnClick, doOnLongClick) { +) : BindingViewHolder(binding.root) { - override fun onBind(data: Album) { - binding.parentImage.bindAlbumCover(data) - binding.parentName.textSafe = data.resolvedName - binding.parentInfo.textSafe = data.resolvedArtistName + override fun bind(item: Album, listener: MenuItemListener) { + binding.parentImage.bindAlbumCover(item) + binding.parentName.textSafe = item.resolvedName + binding.parentInfo.textSafe = item.resolvedArtistName + binding.root.apply { + setOnClickListener { listener.onItemClick(item) } + setOnLongClickListener { view -> + listener.onOpenMenu(item, view) + true + } + } } companion object { - /** Create an instance of [AlbumViewHolder] */ - fun from( - context: Context, - doOnClick: (data: Album) -> Unit, - doOnLongClick: (view: View, data: Album) -> Unit - ): AlbumViewHolder { - return AlbumViewHolder( - ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick) - } + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_ALBUM + + override fun create(context: Context) = + AlbumViewHolder(ItemParentBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Album, newItem: Album) = + oldItem.resolvedName == newItem.resolvedName && + oldItem.resolvedArtistName == newItem.resolvedArtistName + } } } -/** The Shared ViewHolder for a [Artist]. Instantiation should be done with [from]. */ -class ArtistViewHolder -private constructor( - private val binding: ItemParentBinding, - doOnClick: (Artist) -> Unit, - doOnLongClick: (view: View, data: Artist) -> Unit -) : BaseViewHolder(binding, doOnClick, doOnLongClick) { +/** The Shared ViewHolder for a [Artist]. */ +class ArtistViewHolder private constructor(private val binding: ItemParentBinding) : + BindingViewHolder(binding.root) { - override fun onBind(data: Artist) { - binding.parentImage.bindArtistImage(data) - binding.parentName.textSafe = data.resolvedName + override fun bind(item: Artist, listener: MenuItemListener) { + binding.parentImage.bindArtistImage(item) + binding.parentName.textSafe = item.resolvedName binding.parentInfo.textSafe = binding.context.getString( R.string.fmt_two, - binding.context.getPluralSafe(R.plurals.fmt_album_count, data.albums.size), - binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size)) + binding.context.getPluralSafe(R.plurals.fmt_album_count, item.albums.size), + binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size)) + binding.root.apply { + setOnClickListener { listener.onItemClick(item) } + setOnLongClickListener { view -> + listener.onOpenMenu(item, view) + true + } + } } companion object { - /** Create an instance of [ArtistViewHolder] */ - fun from( - context: Context, - doOnClick: (Artist) -> Unit, - doOnLongClick: (view: View, data: Artist) -> Unit - ): ArtistViewHolder { - return ArtistViewHolder( - ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick) - } + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_ARTIST + + override fun create(context: Context) = + ArtistViewHolder(ItemParentBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Artist, newItem: Artist) = + oldItem.resolvedName == newItem.resolvedName && + oldItem.albums.size == newItem.albums.size && + newItem.songs.size == newItem.songs.size + } } } -/** The Shared ViewHolder for a [Genre]. Instantiation should be done with [from]. */ +/** The Shared ViewHolder for a [Genre]. */ class GenreViewHolder private constructor( private val binding: ItemParentBinding, - doOnClick: (Genre) -> Unit, - doOnLongClick: (view: View, data: Genre) -> Unit -) : BaseViewHolder(binding, doOnClick, doOnLongClick) { +) : BindingViewHolder(binding.root) { - override fun onBind(data: Genre) { - binding.parentImage.bindGenreImage(data) - binding.parentName.textSafe = data.resolvedName + override fun bind(item: Genre, listener: MenuItemListener) { + binding.parentImage.bindGenreImage(item) + binding.parentName.textSafe = item.resolvedName binding.parentInfo.textSafe = - binding.context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size) + binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size) + binding.root.apply { + setOnClickListener { listener.onItemClick(item) } + setOnLongClickListener { view -> + listener.onOpenMenu(item, view) + true + } + } } companion object { - /** Create an instance of [GenreViewHolder] */ - fun from( - context: Context, - doOnClick: (Genre) -> Unit, - doOnLongClick: (view: View, data: Genre) -> Unit - ): GenreViewHolder { - return GenreViewHolder( - ItemParentBinding.inflate(context.inflater), doOnClick, doOnLongClick) - } + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_GENRE + + override fun create(context: Context) = + GenreViewHolder(ItemParentBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback() { + override fun areItemsTheSame(oldItem: Genre, newItem: Genre): Boolean = + oldItem.resolvedName == newItem.resolvedName && + oldItem.songs.size == newItem.songs.size + } } } /** The Shared ViewHolder for a [Header]. Instantiation should be done with [from] */ -class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : - BaseViewHolder
(binding) { +class NewHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : + BindingViewHolder(binding.root) { - override fun onBind(data: Header) { - binding.title.textSafe = binding.context.getString(data.string) + override fun bind(item: Header, listener: Unit) { + binding.title.textSafe = binding.context.getString(item.string) } companion object { - /** Create an instance of [HeaderViewHolder] */ - fun from(context: Context): HeaderViewHolder { - return HeaderViewHolder(ItemHeaderBinding.inflate(context.inflater)) - } - } -} - -/** The Shared ViewHolder for an [ActionHeader]. Instantiation should be done with [from] */ -class ActionHeaderViewHolder private constructor(private val binding: ItemActionHeaderBinding) : - BaseViewHolder(binding) { - - override fun onBind(data: ActionHeader) { - binding.headerTitle.textSafe = binding.context.getString(data.string) - binding.headerButton.apply { - setImageResource(data.icon) - contentDescription = context.getString(data.desc) - TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener(data.onClick) - } - } - - companion object { - /** Create an instance of [ActionHeaderViewHolder] */ - fun from(context: Context): ActionHeaderViewHolder { - return ActionHeaderViewHolder(ItemActionHeaderBinding.inflate(context.inflater)) - } + val CREATOR = + object : Creator { + override val viewType: Int + get() = IntegerTable.ITEM_TYPE_HEADER + + override fun create(context: Context) = + NewHeaderViewHolder(ItemHeaderBinding.inflate(context.inflater)) + } + + val DIFFER = + object : ItemDiffCallback
() { + override fun areItemsTheSame(oldItem: Header, newItem: Header): Boolean = + oldItem.string == newItem.string + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt similarity index 89% rename from app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt rename to app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt index 4361722f3..ec72b3047 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.util import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.os.Looper +import androidx.fragment.app.Fragment /** * Shortcut for querying all items in a database and running [block] with the cursor returned. Will @@ -34,3 +35,9 @@ fun assertBackgroundThread() { "This operation must be ran on a background thread" } } + +fun Fragment.requireAttached() { + if (isDetached) { + error("Fragment is detached from activity") + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt index 5c8ab6826..2b36926db 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt @@ -123,6 +123,13 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) { } } +@Suppress("UNCHECKED_CAST") +fun RecyclerView.getViewHolderAt(pos: Int): RecyclerView.ViewHolder? { + return layoutManager?.run { + findViewByPosition(pos)?.let { child -> getChildViewHolder(child) } + } +} + /** Returns whether a recyclerview can scroll. */ fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height diff --git a/app/src/main/res/layout/item_album_song.xml b/app/src/main/res/layout/item_album_song.xml index 0e39ad4f8..895fbf08f 100644 --- a/app/src/main/res/layout/item_album_song.xml +++ b/app/src/main/res/layout/item_album_song.xml @@ -26,11 +26,12 @@ android:layout_height="wrap_content" android:gravity="center" android:maxLines="1" - android:minWidth="@dimen/size_track_number" + android:minWidth="@dimen/size_btn_small" + android:minHeight="@dimen/size_btn_small" android:textAlignment="center" android:textAppearance="@style/TextAppearance.Auxio.BodyLarge" android:textColor="@color/sel_accented_secondary" - android:textSize="@dimen/text_size_ext_title_mid_large" + android:textSize="@dimen/text_size_track_number" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/song_name" app:layout_constraintHorizontal_bias="0.5" diff --git a/app/src/main/res/layout/item_action_header.xml b/app/src/main/res/layout/item_sort_header.xml similarity index 94% rename from app/src/main/res/layout/item_action_header.xml rename to app/src/main/res/layout/item_sort_header.xml index 73b799a7f..6983534ee 100644 --- a/app/src/main/res/layout/item_action_header.xml +++ b/app/src/main/res/layout/item_sort_header.xml @@ -29,8 +29,8 @@ app:layout_constraintBottom_toTopOf="@id/header_divider" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:ignore="ContentDescription" - tools:src="@drawable/ic_sort" /> + android:contentDescription="@string/lbl_sort" + android:src="@drawable/ic_sort" /> 16sp 18sp + 22sp 2dp