detail: separate header from list data

Separate the header information into it's own adapter, and concatenate
it with the prior adapter.

This way, it can be updated separately without a jarring list update.
This commit is contained in:
Alexander Capehart 2023-03-19 12:02:29 -06:00
parent f57b5dfc81
commit 9cc75a0e11
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
33 changed files with 570 additions and 490 deletions

View file

@ -37,18 +37,12 @@ object IntegerTable {
const val VIEW_TYPE_BASIC_HEADER = 0xA004
/** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA005
/** AlbumDetailViewHolder */
const val VIEW_TYPE_ALBUM_DETAIL = 0xA006
/** AlbumSongViewHolder */
const val VIEW_TYPE_ALBUM_SONG = 0xA007
/** ArtistDetailViewHolder */
const val VIEW_TYPE_ARTIST_DETAIL = 0xA008
/** ArtistAlbumViewHolder */
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
/** ArtistSongViewHolder */
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** GenreDetailViewHolder */
const val VIEW_TYPE_GENRE_DETAIL = 0xA00B
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00C
/** "Music playback" notification code */

View file

@ -25,12 +25,15 @@ import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
@ -49,10 +52,15 @@ import org.oxycblt.auxio.util.*
* A [ListFragment] that shows information about an [Album].
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Split up list and header adapters, and then work from there. Header item works fine. Make
* sure that other pos-dependent code functions
*/
@AndroidEntryPoint
class AlbumDetailFragment :
ListFragment<Song, FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
ListFragment<Song, FragmentDetailBinding>(),
AlbumDetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> {
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
@ -60,7 +68,8 @@ class AlbumDetailFragment :
// Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album.
private val args: AlbumDetailFragmentArgs by navArgs()
private val detailAdapter = AlbumDetailAdapter(this)
private val albumHeaderAdapter = AlbumDetailHeaderAdapter(this)
private val albumListAdapter = AlbumDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -87,7 +96,7 @@ class AlbumDetailFragment :
setOnMenuItemClickListener(this@AlbumDetailFragment)
}
binding.detailRecycler.adapter = detailAdapter
binding.detailRecycler.adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
// -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
@ -185,14 +194,15 @@ class AlbumDetailFragment :
return
}
requireBinding().detailToolbar.title = album.resolveName(requireContext())
albumHeaderAdapter.setParent(album)
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
detailAdapter.setPlaying(song, isPlaying)
albumListAdapter.setPlaying(song, isPlaying)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.setPlaying(null, isPlaying)
albumListAdapter.setPlaying(null, isPlaying)
}
}
@ -277,11 +287,11 @@ class AlbumDetailFragment :
}
private fun updateList(list: List<Item>) {
detailAdapter.update(list, detailModel.albumInstructions.consume())
albumListAdapter.update(list, detailModel.albumInstructions.consume())
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelected(selected.toSet())
albumListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -25,12 +25,15 @@ import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
@ -51,7 +54,9 @@ import org.oxycblt.auxio.util.*
*/
@AndroidEntryPoint
class ArtistDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
@ -59,7 +64,8 @@ class ArtistDetailFragment :
// Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs()
private val detailAdapter = ArtistDetailAdapter(this)
private val artistHeaderAdapter = ArtistDetailHeaderAdapter(this)
private val artistListAdapter = ArtistDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -86,7 +92,7 @@ class ArtistDetailFragment :
setOnMenuItemClickListener(this@ArtistDetailFragment)
}
binding.detailRecycler.adapter = detailAdapter
binding.detailRecycler.adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
@ -194,8 +200,8 @@ class ArtistDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = artist.resolveName(requireContext())
artistHeaderAdapter.setParent(artist)
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
@ -210,7 +216,7 @@ class ArtistDetailFragment :
else -> null
}
detailAdapter.setPlaying(playingItem, isPlaying)
artistListAdapter.setPlaying(playingItem, isPlaying)
}
private fun handleNavigation(item: Music?) {
@ -249,11 +255,11 @@ class ArtistDetailFragment :
}
private fun updateList(list: List<Item>) {
detailAdapter.update(list, detailModel.artistInstructions.consume())
artistListAdapter.update(list, detailModel.artistInstructions.consume())
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelected(selected.toSet())
artistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -30,7 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort
@ -270,7 +270,7 @@ constructor(
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album data")
val list = mutableListOf<Item>(album)
val list = mutableListOf<Item>()
list.add(SortHeader(R.string.lbl_songs))
val instructions =
if (replace) {
@ -302,7 +302,7 @@ constructor(
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
logD("Refreshing artist data")
val list = mutableListOf<Item>(artist)
val list = mutableListOf<Item>()
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
val byReleaseGroup =
@ -351,7 +351,7 @@ constructor(
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
logD("Refreshing genre data")
val list = mutableListOf<Item>(genre)
val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs.
list.add(BasicHeader(R.string.lbl_artists))
list.addAll(genre.artists)

View file

@ -25,12 +25,15 @@ import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
@ -52,7 +55,9 @@ import org.oxycblt.auxio.util.*
*/
@AndroidEntryPoint
class GenreDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
@ -60,7 +65,8 @@ class GenreDetailFragment :
// Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre.
private val args: GenreDetailFragmentArgs by navArgs()
private val detailAdapter = GenreDetailAdapter(this)
private val genreHeaderAdapter = GenreDetailHeaderAdapter(this)
private val genreListAdapter = GenreDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -85,7 +91,7 @@ class GenreDetailFragment :
setOnMenuItemClickListener(this@GenreDetailFragment)
}
binding.detailRecycler.adapter = detailAdapter
binding.detailRecycler.adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
@ -191,8 +197,8 @@ class GenreDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = genre.resolveName(requireContext())
genreHeaderAdapter.setParent(genre)
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
@ -204,7 +210,7 @@ class GenreDetailFragment :
if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) {
playingMusic = song
}
detailAdapter.setPlaying(playingMusic, isPlaying)
genreListAdapter.setPlaying(playingMusic, isPlaying)
}
private fun handleNavigation(item: Music?) {
@ -232,11 +238,11 @@ class GenreDetailFragment :
}
private fun updateList(list: List<Item>) {
detailAdapter.update(list, detailModel.genreInstructions.consume())
genreListAdapter.update(list, detailModel.genreInstructions.consume())
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelected(selected.toSet())
genreListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -29,8 +29,8 @@ import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.recycler.SongProperty
import org.oxycblt.auxio.detail.recycler.SongPropertyAdapter
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song

View file

@ -0,0 +1,112 @@
/*
* Copyright (c) 2023 Auxio Project
* AlbumDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Album] information.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Album, AlbumDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: AlbumDetailHeaderViewHolder, parent: Album) =
holder.bind(parent, listener)
/** An extended listener for [DetailHeaderAdapter] implementations. */
interface Listener : DetailHeaderAdapter.Listener {
/**
* Called when the artist name in the [Album] header was clicked, requesting navigation to
* it's parent artist.
*/
fun onNavigateToParentArtist()
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param album The new [Album] to bind.
* @param listener A [AlbumDetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(album: Album, listener: AlbumDetailHeaderAdapter.Listener) {
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
binding.detailName.text = album.resolveName(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {
text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
setOnClickListener { listener.onNavigateToParentArtist() }
}
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
AlbumDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2023 Auxio Project
* ArtistDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Artist] information.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Artist, ArtistDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: ArtistDetailHeaderViewHolder, parent: Artist) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param artist The new [Artist] to bind.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = artist.resolveName(binding.context)
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
text = artist.genres.resolveNames(context)
}
// Song and album counts map to the info
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size))
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton.isVisible = true
binding.detailShuffleButton.isVisible = true
} else {
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
binding.detailSubhead.isVisible = false
binding.detailInfo.text =
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
binding.detailPlayButton.isVisible = false
binding.detailShuffleButton.isVisible = false
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
ArtistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2023 Auxio Project
* DetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.MusicParent
/**
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
private var currentParent: T? = null
final override fun getItemCount() = 1
final override fun onBindViewHolder(holder: VH, position: Int) =
onBindHeader(holder, requireNotNull(currentParent))
/**
* Bind the created header [RecyclerView.ViewHolder] with the current [parent].
* @param holder The [RecyclerView.ViewHolder] to bind.
* @param parent The current [MusicParent] to bind.
*/
abstract fun onBindHeader(holder: VH, parent: T)
/**
* Update the [MusicParent] shown in the header.
* @param parent The new [MusicParent] to show.
*/
fun setParent(parent: T) {
currentParent = parent
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
}
/** An extended listener for [DetailHeaderAdapter] implementations. */
interface Listener {
/**
* Called when the play button in a detail header is pressed, requesting that the current
* item should be played.
*/
fun onPlay()
/**
* Called when the shuffle button in a detail header is pressed, requesting that the current
* item should be shuffled
*/
fun onShuffle()
}
private companion object {
val PAYLOAD_UPDATE_HEADER = Any()
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 Auxio Project
* GenreDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Genre] information.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Genre, GenreDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: GenreDetailHeaderViewHolder, parent: Genre) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param genre The new [Genre] to bind.
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
*/
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = genre.resolveName(binding.context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song count of the genre maps to the info text.
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
GenreDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* AlbumDetailAdapter.kt is part of Auxio.
* AlbumDetailListAdapter.kt is part of Auxio.
*
* 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
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.recycler
package org.oxycblt.auxio.detail.list
import android.view.View
import android.view.ViewGroup
@ -26,7 +26,6 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
@ -34,37 +33,22 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.areRawNamesTheSame
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* An [DetailAdapter] implementing the header and sub-items for the [Album] detail view.
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
*
* @param listener A [Listener] to bind interactions to.
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
/**
* An extension to [DetailAdapter.Listener] that enables interactions specific to the album
* detail view.
*/
interface Listener : DetailAdapter.Listener<Song> {
/**
* Called when the artist name in the [Album] header was clicked, requesting navigation to
* it's parent artist.
*/
fun onNavigateToParentArtist()
}
class AlbumDetailListAdapter(private val listener: Listener<Song>) :
DetailListAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support the Album header, sub-headers for each disc, and special album songs.
is Album -> AlbumDetailViewHolder.VIEW_TYPE
// Support sub-headers for each disc, and special album songs.
is Disc -> DiscViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@ -72,7 +56,6 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
@ -81,7 +64,6 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
when (val item = getItem(position)) {
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
is Disc -> (holder as DiscViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
}
@ -100,93 +82,18 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Album && newItem is Album ->
AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Disc && newItem is Disc ->
DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
// Fall back to DetailAdapter's differ to handle other headers.
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class AlbumDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param album The new [Album] to bind.
* @param listener A [AlbumDetailAdapter.Listener] to bind interactions to.
*/
fun bind(album: Album, listener: AlbumDetailAdapter.Listener) {
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
binding.detailName.text = album.resolveName(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {
text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
setOnClickListener { listener.onNavigateToParentArtist() }
}
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_DETAIL
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.artists.areRawNamesTheSame(newItem.artists) &&
oldItem.dates == newItem.dates &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs &&
oldItem.releaseType == newItem.releaseType
}
}
}
/**

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* ArtistDetailAdapter.kt is part of Auxio.
* ArtistDetailListAdapter.kt is part of Auxio.
*
* 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
@ -16,15 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.recycler
package org.oxycblt.auxio.detail.list
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.Item
@ -33,21 +31,19 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailAdapter] implementing the header and sub-items for the [Artist] detail view.
* A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
*
* @param listener A [DetailAdapter.Listener] to bind interactions to.
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailAdapter(private val listener: Listener<Music>) :
DetailAdapter(listener, DIFF_CALLBACK) {
class ArtistDetailListAdapter(private val listener: Listener<Music>) :
DetailListAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support an artist header, and special artist albums/songs.
is Artist -> ArtistDetailViewHolder.VIEW_TYPE
// Support a special artist albums/songs.
is Album -> ArtistAlbumViewHolder.VIEW_TYPE
is Song -> ArtistSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@ -55,7 +51,6 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.from(parent)
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent)
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
@ -65,7 +60,6 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
super.onBindViewHolder(holder, position)
// Re-binding an item with new data and not just a changed selection/playing state.
when (val item = getItem(position)) {
is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener)
is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
is Song -> (holder as ArtistSongViewHolder).bind(item, listener)
}
@ -83,98 +77,16 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Artist && newItem is Artist ->
ArtistDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(
oldItem, newItem)
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Album && newItem is Album ->
ArtistAlbumViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
ArtistSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param artist The new [Artist] to bind.
* @param listener A [DetailAdapter.Listener] to bind interactions to.
*/
fun bind(artist: Artist, listener: DetailAdapter.Listener<*>) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = artist.resolveName(binding.context)
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
text = artist.genres.resolveNames(context)
}
// Song and album counts map to the info
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size))
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton.isVisible = true
binding.detailShuffleButton.isVisible = true
} else {
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
binding.detailSubhead.isVisible = false
binding.detailInfo.text =
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
binding.detailPlayButton.isVisible = false
binding.detailShuffleButton.isVisible = false
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_DETAIL
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName &&
oldItem.genres.areRawNamesTheSame(newItem.genres) &&
oldItem.albums.size == newItem.albums.size &&
oldItem.songs.size == newItem.songs.size
}
}
}
/**

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* DetailAdapter.kt is part of Auxio.
* DetailListAdapter.kt is part of Auxio.
*
* 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
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.recycler
package org.oxycblt.auxio.detail.list
import android.view.View
import android.view.ViewGroup
@ -37,13 +37,14 @@ import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters.
* A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the
* detail views.
*
* @param listener A [Listener] to bind interactions to.
* @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class DetailAdapter(
abstract class DetailListAdapter(
private val listener: Listener<*>,
private val diffCallback: DiffUtil.ItemCallback<Item>
) :
@ -78,21 +79,8 @@ abstract class DetailAdapter(
return item is BasicHeader || item is SortHeader
}
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */
/** An extended [SelectableListListener] for [DetailListAdapter] implementations. */
interface Listener<in T : Music> : SelectableListListener<T> {
// TODO: Split off into sub-listeners if a collapsing toolbar is implemented.
/**
* Called when the play button in a detail header is pressed, requesting that the current
* item should be played.
*/
fun onPlay()
/**
* Called when the shuffle button in a detail header is pressed, requesting that the current
* item should be shuffled
*/
fun onShuffle()
/**
* Called when the button in a [SortHeader] item is pressed, requesting that the sort menu
* should be opened.
@ -137,9 +125,9 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
* Bind new data to this instance.
*
* @param sortHeader The new [SortHeader] to bind.
* @param listener An [DetailAdapter.Listener] to bind interactions to.
* @param listener An [DetailListAdapter.Listener] to bind interactions to.
*/
fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener<*>) {
fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
binding.headerButton.apply {
// Add a Tooltip based on the content description so that the purpose of this

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2021 Auxio Project
* GenreDetailListAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.list
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
/**
* An [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailListAdapter(private val listener: Listener<Music>) :
DetailListAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support generic Artist/Song items.
is Artist -> ArtistViewHolder.VIEW_TYPE
is Song -> SongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
when (val item = getItem(position)) {
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
is Song -> (holder as SongViewHolder).bind(item, listener)
}
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Genre headers should be full-width in all configurations
return getItem(position) is Genre
}
private companion object {
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Artist && newItem is Artist ->
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.recycler
package org.oxycblt.auxio.detail.list
import android.view.View
import android.view.ViewGroup

View file

@ -1,154 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* GenreDetailAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* An [DetailAdapter] implementing the header and sub-items for the [Genre] detail view.
*
* @param listener A [DetailAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailAdapter(private val listener: Listener<Music>) :
DetailAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support the Genre header and generic Artist/Song items. There's nothing about
// a genre that will make the artists/songs specially formatted, so it doesn't matter
// what we use for their ViewHolders.
is Genre -> GenreDetailViewHolder.VIEW_TYPE
is Artist -> ArtistViewHolder.VIEW_TYPE
is Song -> SongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.from(parent)
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
when (val item = getItem(position)) {
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
is Song -> (holder as SongViewHolder).bind(item, listener)
}
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Genre headers should be full-width in all configurations
return getItem(position) is Genre
}
private companion object {
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Genre && newItem is Genre ->
GenreDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Artist && newItem is Artist ->
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param genre The new [Song] to bind.
* @param listener A [DetailAdapter.Listener] to bind interactions to.
*/
fun bind(genre: Genre, listener: DetailAdapter.Listener<*>) {
binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = genre.resolveName(binding.context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song count of the genre maps to the info text.
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE_DETAIL
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.rawName == newItem.rawName &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs
}
}
}

View file

@ -34,7 +34,6 @@ import org.oxycblt.auxio.image.extractor.*
@InstallIn(SingletonComponent::class)
interface ImageModule {
@Binds fun settings(imageSettings: ImageSettingsImpl): ImageSettings
@Binds fun coverExtractor(coverExtractor: CoverExtractorImpl): CoverExtractor
}
@Module

View file

@ -38,33 +38,15 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/**
* Stateless interface for loading [Album] cover image data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface CoverExtractor {
/**
* Fetch an album cover, respecting the current cover configuration.
*
* @param context [Context] required to load the image.
* @param imageSettings [ImageSettings] required to obtain configuration information.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null if the cover
* loading failed or should not occur.
*/
suspend fun extract(album: Album): InputStream?
}
class CoverExtractorImpl
class CoverExtractor
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) : CoverExtractor {
) {
override suspend fun extract(album: Album): InputStream? =
suspend fun extract(album: Album): InputStream? =
try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null

View file

@ -35,6 +35,7 @@ import org.oxycblt.auxio.util.logD
abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
diffCallback: DiffUtil.ItemCallback<T>
) : RecyclerView.Adapter<VH>() {
@Suppress("LeakingThis")
private val differ = FlexibleListDiffer(this, diffCallback)
final override fun getItemCount() = differ.currentList.size
/** The current list stored by the adapter's differ instance. */
@ -55,9 +56,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
instructions: UpdateInstructions?,
callback: (() -> Unit)? = null
) =
differ.update(newData, instructions, callback).also {
logD("Update delivered: $instructions" + "")
}
differ.update(newData, instructions, callback)
}
/**

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.list.recycler
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.BackportMaterialDividerItemDecoration
@ -41,12 +42,26 @@ constructor(
defStyleAttr: Int = R.attr.materialDividerStyle,
orientation: Int = LinearLayoutManager.VERTICAL
) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) {
override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?) =
override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?): Boolean {
if (adapter is ConcatAdapter) {
val adapterAndPosition =
try {
adapter.getWrappedAdapterAndPosition(position + 1)
} catch (e: IllegalArgumentException) {
return false
}
return hasHeaderAtPosition(adapterAndPosition.second, adapterAndPosition.first)
} else {
return hasHeaderAtPosition(position + 1, adapter)
}
}
private fun hasHeaderAtPosition(position: Int, adapter: RecyclerView.Adapter<*>?) =
try {
// Add a divider if the next item is a header. This organizes the divider to separate
// the ends of content rather than the beginning of content, alongside an added benefit
// of preventing top headers from having a divider applied.
(adapter as FlexibleListAdapter<*, *>).getItem(position + 1) is Header
(adapter as FlexibleListAdapter<*, *>).getItem(position) is Header
} catch (e: ClassCastException) {
false
} catch (e: IndexOutOfBoundsException) {

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.12" android:color="?attr/colorOnSurface" android:state_enabled="false" />
<item android:color="?attr/colorOnPrimary" android:state_checked="true" />
<item android:color="?attr/colorSurfaceVariant" />
</selector>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.38" android:color="?attr/colorOnSurface" android:state_enabled="false" />
<item android:color="?attr/colorPrimary" android:state_checked="true" />
<item android:color="?attr/colorOnSurfaceVariant" />
</selector>

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="4dp"
android:left="4dp"
android:right="4dp"
android:top="4dp">
<shape android:shape="oval">
<solid android:color="#000000" />
<size
android:width="20dp"
android:height="20dp" />
</shape>
</item>
</layer-list>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#000000" />
<corners android:radius="56dp" />
<size
android:width="64dp"
android:height="28dp" />
</shape>
</item>
</layer-list>

View file

@ -35,6 +35,6 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_detail" />
tools:listitem="@layout/item_detail_header" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -226,7 +226,7 @@
<string name="fmt_db_pos">+%.1f dB</string>
<string name="fmt_bitrate">%d kbps</string>
<string name="fmt_sample_rate">%d Hz</string>
<string name="fmt_indexing">Cargando a túa biblioteca de música... (%1$d/%2$d)</string>
<string name="fmt_indexing">Cargando a túa biblioteca de música (%1$d/%2$d)</string>
<string name="fmt_lib_song_count">Cancións cargadas: %d</string>
<string name="fmt_lib_album_count">Álbums cargados: %d</string>
<string name="fmt_lib_artist_count">Artistas cargados: %d</string>

View file

@ -33,7 +33,7 @@
<item name="textAppearanceHeadlineSmall">@style/TextAppearance.Auxio.HeadlineSmall</item>
<item name="textAppearanceTitleLarge">@style/TextAppearance.Auxio.TitleLarge</item>
<item name="textAppearanceTitleMedium">@style/TextAppearance.Auxio.TitleMediumLowEmphasis
<item name="textAppearanceTitleMedium">@style/TextAppearance.Auxio.TitleMedium
</item>
<item name="textAppearanceTitleSmall">@style/TextAppearance.Auxio.TitleSmall</item>

View file

@ -72,13 +72,6 @@
</style>
<style name="TextAppearance.Auxio.TitleMedium" parent="TextAppearance.Material3.TitleMedium">
<item name="fontFamily">@font/inter_semibold</item>
<item name="android:fontFamily">@font/inter_semibold</item>
<item name="android:textStyle">bold</item>
<item name="android:letterSpacing">-0.00825</item>
</style>
<style name="TextAppearance.Auxio.TitleMediumLowEmphasis" parent="TextAppearance.Material3.TitleMedium">
<item name="fontFamily">@font/inter_regular</item>
<item name="android:fontFamily">@font/inter_regular</item>
<item name="android:textStyle">normal</item>