Add artist songs list

Add a list of artist songs to ArtistDetailFragment. This moves the artist sort functionality to the song list instead of the album list.
This commit is contained in:
OxygenCobalt 2021-04-26 16:54:49 -06:00
parent cc72ebc251
commit b9506bcbc3
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
17 changed files with 254 additions and 110 deletions

View file

@ -65,7 +65,9 @@ class AlbumDetailFragment : DetailFragment() {
}
}
setupRecycler(detailAdapter)
setupRecycler(detailAdapter) { pos ->
pos == 0
}
// -- DETAILVIEWMODEL SETUP ---

View file

@ -6,19 +6,22 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.adapters.ArtistDetailAdapter
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.recycler.SortMode
import org.oxycblt.auxio.ui.ActionMenu
import org.oxycblt.auxio.ui.newMenu
/**
* The [DetailFragment] for an artist.
* TODO: Show a list of songs?
* @author OxygenCobalt
*/
class ArtistDetailFragment : DetailFragment() {
@ -42,14 +45,18 @@ class ArtistDetailFragment : DetailFragment() {
}
val detailAdapter = ArtistDetailAdapter(
detailModel, playbackModel, viewLifecycleOwner,
doOnClick = { album ->
if (!detailModel.isNavigating) {
detailModel.setNavigating(true)
playbackModel,
doOnClick = { data ->
if (data is Album) {
if (!detailModel.isNavigating) {
detailModel.setNavigating(true)
findNavController().navigate(
ArtistDetailFragmentDirections.actionShowAlbum(album.id)
)
findNavController().navigate(
ArtistDetailFragmentDirections.actionShowAlbum(data.id)
)
}
} else if (data is Song) {
playbackModel.playSong(data, PlaybackMode.IN_ARTIST)
}
},
doOnLongClick = { view, data ->
@ -57,24 +64,40 @@ class ArtistDetailFragment : DetailFragment() {
}
)
// We build the action header here since it's both more efficent to keep one action header
// and it also prevents the header from being constantly refreshed when the sort is updated.
val songsHeader = ActionHeader(
id = -2,
name = getString(R.string.label_songs),
icon = detailModel.artistSortMode.value!!.iconRes,
) { btn ->
detailModel.incrementArtistSortMode()
// We'll update the icon of this header object directly so that the state persists
// after the viewholder is recycled.
icon = detailModel.artistSortMode.value!!.iconRes
btn.setImageResource(icon)
}
// --- UI SETUP ---
binding.lifecycleOwner = this
setupToolbar()
setupRecycler(detailAdapter)
setupRecycler(detailAdapter) { pos ->
// If the item is an ActionHeader we need to also make the item full-width
pos == 0 || detailAdapter.currentList.getOrNull(pos) is ActionHeader
}
detailAdapter.submitList(createData(songsHeader, detailModel.artistSortMode.value!!))
// --- VIEWMODEL SETUP ---
detailModel.artistSortMode.observe(viewLifecycleOwner) { mode ->
logD("Updating sort mode to $mode")
// Header detail data is always included
val data = mutableListOf<BaseModel>(detailModel.currentArtist.value!!).also {
it.addAll(mode.getSortedAlbumList(detailModel.currentArtist.value!!.albums))
}
detailAdapter.submitList(data)
detailAdapter.submitList(createData(songsHeader, mode))
}
detailModel.navToItem.observe(viewLifecycleOwner) { item ->
@ -105,9 +128,21 @@ class ArtistDetailFragment : DetailFragment() {
// Highlight albums if they are being played
playbackModel.parent.observe(viewLifecycleOwner) { parent ->
if (parent is Album?) {
detailAdapter.setCurrentAlbum(parent, binding.detailRecycler)
detailAdapter.highlightAlbum(parent, binding.detailRecycler)
} else {
detailAdapter.setCurrentAlbum(null, binding.detailRecycler)
detailAdapter.highlightAlbum(null, binding.detailRecycler)
}
}
// Highlight songs if they are being played
playbackModel.song.observe(viewLifecycleOwner) { song ->
if (playbackModel.mode.value == PlaybackMode.IN_ARTIST &&
playbackModel.parent.value?.id == detailModel.currentArtist.value!!.id
) {
detailAdapter.highlightSong(song, binding.detailRecycler)
} else {
// Clear the viewholders if the mode isn't ALL_SONGS
detailAdapter.highlightSong(null, binding.detailRecycler)
}
}
@ -115,4 +150,15 @@ class ArtistDetailFragment : DetailFragment() {
return binding.root
}
private fun createData(songHeader: ActionHeader, mode: SortMode): MutableList<BaseModel> {
val artist = detailModel.currentArtist.value!!
val data = mutableListOf<BaseModel>(artist)
data.addAll(SortMode.NUMERIC_DOWN.getSortedAlbumList(artist.albums))
data.add(songHeader)
data.addAll(mode.getSortedArtistSongList(artist.songs))
return data
}
}

View file

@ -8,10 +8,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.fixAnimInfoLeak
import org.oxycblt.auxio.ui.isLandscape
@ -78,7 +76,10 @@ abstract class DetailFragment : Fragment() {
/**
* Shortcut method for recyclerview setup
*/
protected fun setupRecycler(detailAdapter: ListAdapter<BaseModel, RecyclerView.ViewHolder>) {
protected fun setupRecycler(
detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>,
gridLookup: (Int) -> Boolean
) {
binding.detailRecycler.apply {
adapter = detailAdapter
setHasFixedSize(true)
@ -88,7 +89,7 @@ abstract class DetailFragment : Fragment() {
layoutManager = GridLayoutManager(requireContext(), 2).also {
it.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return if (position == 0) 2 else 1
return if (gridLookup(position)) 2 else 1
}
}
}

View file

@ -56,7 +56,9 @@ class GenreDetailFragment : DetailFragment() {
binding.lifecycleOwner = this
setupToolbar()
setupRecycler(detailAdapter)
setupRecycler(detailAdapter) { pos ->
pos == 0
}
// --- DETAILVIEWMODEL SETUP ---

View file

@ -2,21 +2,23 @@ package org.oxycblt.auxio.detail.adapters
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemArtistAlbumBinding
import org.oxycblt.auxio.databinding.ItemArtistHeaderBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.databinding.ItemArtistSongBinding
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.viewholders.ActionHeaderViewHolder
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
import org.oxycblt.auxio.recycler.viewholders.Highlightable
import org.oxycblt.auxio.ui.Accent
import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.inflater
import org.oxycblt.auxio.ui.setTextColorResource
@ -25,19 +27,22 @@ import org.oxycblt.auxio.ui.setTextColorResource
* @author OxygenCobalt
*/
class ArtistDetailAdapter(
private val detailModel: DetailViewModel,
private val playbackModel: PlaybackViewModel,
private val lifecycleOwner: LifecycleOwner,
private val doOnClick: (data: Album) -> Unit,
private val doOnLongClick: (view: View, data: Album) -> Unit,
private val doOnClick: (data: BaseModel) -> Unit,
private val doOnLongClick: (view: View, data: BaseModel) -> Unit,
) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback()) {
private var currentAlbum: Album? = null
private var lastHolder: Highlightable? = 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 -> ARTIST_HEADER_ITEM_TYPE
is Album -> ARTIST_ALBUM_ITEM_TYPE
is ActionHeader -> ActionHeaderViewHolder.ITEM_TYPE
is Song -> ARTIST_SONG_ITEM_TYPE
else -> -1
}
@ -53,24 +58,43 @@ class ArtistDetailAdapter(
ItemArtistAlbumBinding.inflate(parent.context.inflater)
)
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
ARTIST_SONG_ITEM_TYPE -> ArtistSongViewHolder(
ItemArtistSongBinding.inflate(parent.context.inflater)
)
else -> error("Invalid ViewHolder item type $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = getItem(position)) {
val item = getItem(position)
when (item) {
is Artist -> (holder as ArtistHeaderViewHolder).bind(item)
is Album -> (holder as ArtistAlbumViewHolder).bind(item)
is ActionHeader -> (holder as ActionHeaderViewHolder).bind(item)
is Song -> (holder as ArtistSongViewHolder).bind(item)
else -> {}
}
if (currentAlbum != null && position > 0) {
if (getItem(position).id == currentAlbum?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted
lastHolder?.setHighlighted(false)
lastHolder = (holder as Highlightable)
holder.setHighlighted(true)
} else {
(holder as Highlightable).setHighlighted(false)
if (holder is Highlightable) {
when (item.id) {
currentAlbum?.id -> {
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = holder
holder.setHighlighted(true)
}
currentSong?.id -> {
currentSongHolder?.setHighlighted(false)
currentSongHolder = holder
holder.setHighlighted(true)
}
else -> holder.setHighlighted(false)
}
}
}
@ -79,11 +103,11 @@ class ArtistDetailAdapter(
* Update the current [album] that this adapter should highlight
* @param recycler The recyclerview the highlighting should act on.
*/
fun setCurrentAlbum(album: Album?, recycler: RecyclerView) {
fun highlightAlbum(album: Album?, recycler: RecyclerView) {
// Clear out the last ViewHolder as a song update usually signifies that this current
// ViewHolder is likely invalid.
lastHolder?.setHighlighted(false)
lastHolder = null
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = null
currentAlbum = album
@ -93,12 +117,46 @@ class ArtistDetailAdapter(
item.name == album.name && item is Album
}
logD(pos)
// Check if the ViewHolder if this album is visible, and highlight it if so.
recycler.layoutManager?.findViewByPosition(pos)?.let { child ->
recycler.getChildViewHolder(child)?.let {
lastHolder = it as Highlightable
currentAlbumHolder = it as Highlightable
lastHolder?.setHighlighted(true)
currentAlbumHolder?.setHighlighted(true)
}
}
}
}
/**
* Update the [song] that this adapter should highlight
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightSong(song: Song?, recycler: RecyclerView) {
// Clear out the last ViewHolder as a song update usually signifies that this current
// ViewHolder is likely invalid.
currentSongHolder?.setHighlighted(false)
currentSongHolder = null
currentSong = song
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.name == song.name && 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)
}
}
}
@ -110,13 +168,7 @@ class ArtistDetailAdapter(
override fun onBind(data: Artist) {
binding.artist = data
binding.detailModel = detailModel
binding.playbackModel = playbackModel
binding.lifecycleOwner = lifecycleOwner
if (data.albums.size < 2) {
binding.artistSortButton.disable()
}
}
}
@ -133,6 +185,8 @@ class ArtistDetailAdapter(
}
override fun setHighlighted(isHighlighted: Boolean) {
logD(isHighlighted)
if (isHighlighted) {
binding.albumName.setTextColorResource(Accent.get().color)
} else {
@ -141,8 +195,29 @@ class ArtistDetailAdapter(
}
}
inner class ArtistSongViewHolder(
private val binding: ItemArtistSongBinding,
) : BaseViewHolder<Song>(binding, doOnClick, doOnLongClick), Highlightable {
private val normalTextColor = binding.songName.currentTextColor
override fun onBind(data: Song) {
binding.song = data
binding.songName.requestLayout()
}
override fun setHighlighted(isHighlighted: Boolean) {
if (isHighlighted) {
binding.songName.setTextColorResource(Accent.get().color)
} else {
binding.songName.setTextColor(normalTextColor)
}
}
}
companion object {
const val ARTIST_HEADER_ITEM_TYPE = 0xA009
const val ARTIST_ALBUM_ITEM_TYPE = 0xA00A
const val ARTIST_SONG_ITEM_TYPE = 0xA00B
}
}

View file

@ -143,7 +143,7 @@ class GenreDetailAdapter(
}
companion object {
const val GENRE_HEADER_ITEM_TYPE = 0xA00B
const val GENRE_SONG_ITEM_TYPE = 0xA00C
const val GENRE_HEADER_ITEM_TYPE = 0xA00C
const val GENRE_SONG_ITEM_TYPE = 0xA00D
}
}

View file

@ -199,12 +199,12 @@ data class Header(
/**
* A data object for a header with an action button. Inherits [BaseModel].
* @property icon The icon ot apply for this header
* @property icon The icon ot apply for this header. This can be changed to reflect any change.
* @property action The callback that will be called when the action button is clicked.
*/
data class ActionHeader(
override val id: Long = -1,
override val name: String = "",
@DrawableRes val icon: Int,
val action: (button: ImageButton) -> Unit,
@DrawableRes var icon: Int,
val action: ActionHeader.(button: ImageButton) -> Unit,
) : BaseModel()

View file

@ -6,7 +6,6 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemActionHeaderBinding
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.ActionHeader
@ -47,9 +46,7 @@ class QueueAdapter(
return when (viewType) {
HeaderViewHolder.ITEM_TYPE -> HeaderViewHolder.from(parent.context)
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder(
ItemActionHeaderBinding.inflate(parent.context.inflater)
)
ActionHeaderViewHolder.ITEM_TYPE -> ActionHeaderViewHolder.from(parent.context)
QUEUE_SONG_ITEM_TYPE -> QueueSongViewHolder(
ItemQueueSongBinding.inflate(parent.context.inflater)

View file

@ -186,7 +186,7 @@ class ActionHeaderViewHolder(
setImageResource(data.icon)
setOnClickListener {
data.action(binding.headerButton)
data.action(data, binding.headerButton)
}
}
}
@ -195,11 +195,11 @@ class ActionHeaderViewHolder(
const val ITEM_TYPE = 0xA006
/**
* Create an instance of [HeaderViewHolder]
* Create an instance of [ActionHeaderViewHolder]
*/
fun from(context: Context): HeaderViewHolder {
return HeaderViewHolder(
ItemHeaderBinding.inflate(context.inflater)
fun from(context: Context): ActionHeaderViewHolder {
return ActionHeaderViewHolder(
ItemActionHeaderBinding.inflate(context.inflater)
)
}
}

View file

@ -15,7 +15,6 @@ import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.PlaybackMode
/**
* Extension method for creating and showing a new [ActionMenu].
@ -78,6 +77,7 @@ class ActionMenu(
when (flag) {
FLAG_NONE, FLAG_IN_GENRE -> R.menu.menu_song_actions
FLAG_IN_ALBUM -> R.menu.menu_album_song_actions
FLAG_IN_ARTIST -> R.menu.menu_artist_song_actions
else -> -1
}
@ -125,12 +125,6 @@ class ActionMenu(
}
}
R.id.action_play_artist -> {
if (flag == FLAG_IN_ALBUM && data is Song) {
playbackModel.playSong(data, PlaybackMode.IN_ARTIST)
}
}
R.id.action_queue_add -> {
when (data) {
is Song -> {

View file

@ -107,17 +107,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/artist_play_button" />
<ImageButton
android:id="@+id/artist_sort_button"
style="@style/HeaderAction"
android:contentDescription="@string/description_sort_button"
android:onClick="@{() -> detailModel.incrementArtistSortMode()}"
app:layout_constraintBottom_toBottomOf="@+id/artist_album_header"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/artist_album_header"
app:sortIcon="@{detailModel.artistSortMode}"
tools:src="@drawable/ic_sort_numeric_down" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -107,17 +107,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/artist_play_button" />
<ImageButton
android:id="@+id/artist_sort_button"
style="@style/HeaderAction"
android:contentDescription="@string/description_sort_button"
android:onClick="@{() -> detailModel.incrementArtistSortMode()}"
app:layout_constraintBottom_toBottomOf="@+id/artist_album_header"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/artist_album_header"
app:sortIcon="@{detailModel.artistSortMode}"
tools:src="@drawable/ic_sort_numeric_down" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -104,17 +104,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/artist_play_button" />
<ImageButton
android:id="@+id/artist_sort_button"
style="@style/HeaderAction"
android:contentDescription="@string/description_sort_button"
android:onClick="@{() -> detailModel.incrementArtistSortMode()}"
app:layout_constraintBottom_toBottomOf="@+id/artist_album_header"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/artist_album_header"
app:sortIcon="@{detailModel.artistSortMode}"
tools:src="@drawable/ic_sort_numeric_down" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".recycler.viewholders.SongViewHolder">
<data>
<variable
name="song"
type="org.oxycblt.auxio.music.Song" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout style="@style/ItemSurroundings">
<ImageView
android:id="@+id/album_cover"
android:layout_width="@dimen/size_cover_compact"
android:layout_height="@dimen/size_cover_compact"
android:contentDescription="@{@string/description_album_cover(song.name)}"
app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:src="@drawable/ic_song" />
<TextView
android:id="@+id/song_name"
style="@style/ItemText.Primary"
android:layout_marginEnd="@dimen/spacing_medium"
android:text="@{song.name}"
app:layout_constraintBottom_toTopOf="@+id/song_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/song_info"
style="@style/ItemText.Secondary"
android:layout_marginEnd="@dimen/spacing_medium"
android:text="@{song.album.name}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toBottomOf="@+id/song_name"
tools:text="Album" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -6,7 +6,4 @@
<item
android:id="@+id/action_go_artist"
android:title="@string/label_go_artist" />
<item
android:id="@+id/action_play_artist"
android:title="@string/label_play_artist" />
</menu>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_queue_add"
android:title="@string/label_queue_add" />
<item
android:id="@+id/action_go_album"
android:icon="@drawable/ic_album"
android:title="@string/label_go_album" />
</menu>

View file

@ -50,8 +50,9 @@ To prevent any strange bugs, all integer representations must be unique. A table
0xA008 | AlbumSongViewHolder
0xA009 | ArtistHeaderViewHolder
0xA00A | ArtistAlbumViewHolder
0xA00B | GenreHeaderViewHolder
0xA00C | GenreSongViewHolder
0xA00B | ArtistSongViewHolder
0xA00C | GenreHeaderViewHolder
0xA00D | GenreSongViewHolder
0xA0A0 | Auxio notification code
0xA0C0 | Auxio request code