detail: update playlist header to reflect edits

Make the header information reflect changes in playlist composition as
the playlist is edited.

This should improve the editing experience to some extent.
This commit is contained in:
Alexander Capehart 2023-05-19 14:33:49 -06:00
parent 5fff1bd0b3
commit cee92c8087
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 70 additions and 42 deletions

View file

@ -282,12 +282,11 @@ class PlaylistDetailFragment :
// TODO: Disable check item when no edits have been made // TODO: Disable check item when no edits have been made
// TODO: Massively improve how this UI is indicated: // TODO: Massively improve how this UI is indicated:
// - Make playlist header dynamically respond to song changes
// - Disable play and pause buttons
// - Add an additional toolbar to indicate editing // - Add an additional toolbar to indicate editing
// - Header should flip to re-sort button eventually // - Header should flip to re-sort button eventually
playlistListAdapter.setEditing(editedPlaylist != null) playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {

View file

@ -48,6 +48,13 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
*/ */
fun setParent(parent: T) { fun setParent(parent: T) {
currentParent = parent currentParent = parent
rebindParent()
}
/**
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
*/
protected fun rebindParent() {
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER) notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
} }

View file

@ -25,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
@ -38,11 +39,27 @@ import org.oxycblt.auxio.util.inflater
*/ */
class PlaylistDetailHeaderAdapter(private val listener: Listener) : class PlaylistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() { DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
private var editedPlaylist: List<Song>? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistDetailHeaderViewHolder.from(parent) PlaylistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) = override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
holder.bind(parent, listener) holder.bind(parent, editedPlaylist, listener)
/**
* Indicate to this adapter that editing is ongoing with the current state of the editing
* process. This will make the header immediately update to reflect information about the edited
* playlist.
*/
fun setEditedPlaylist(songs: List<Song>?) {
if (editedPlaylist == songs) {
// Nothing to do.
return
}
editedPlaylist = songs
rebindParent()
}
} }
/** /**
@ -58,35 +75,39 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
* Bind new data to this instance. * Bind new data to this instance.
* *
* @param playlist The new [Playlist] to bind. * @param playlist The new [Playlist] to bind.
* @param editedPlaylist The current edited state of the playlist, if it exists.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to. * @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/ */
fun bind(playlist: Playlist, listener: DetailHeaderAdapter.Listener) { fun bind(
binding.detailCover.bind(playlist) playlist: Playlist,
editedPlaylist: List<Song>?,
listener: DetailHeaderAdapter.Listener
) {
binding.detailCover.bind(playlist, editedPlaylist)
binding.detailType.text = binding.context.getString(R.string.lbl_playlist) binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context) binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text. // Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false binding.detailSubhead.isVisible = false
val songs = editedPlaylist ?: playlist.songs
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
// The song count of the playlist maps to the info text. // The song count of the playlist maps to the info text.
binding.detailInfo.apply { binding.detailInfo.text =
isVisible = true if (songs.isNotEmpty()) {
text = binding.context.getString(
if (playlist.songs.isNotEmpty()) { R.string.fmt_two,
binding.context.getString( binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
R.string.fmt_two, durationMs.formatDurationMs(true))
binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size), } else {
playlist.durationMs.formatDurationMs(true)) binding.context.getString(R.string.def_song_count)
} else { }
binding.context.getString(R.string.def_song_count)
}
}
binding.detailPlayButton.apply { binding.detailPlayButton.apply {
isEnabled = playlist.songs.isNotEmpty() isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
setOnClickListener { listener.onPlay() } setOnClickListener { listener.onPlay() }
} }
binding.detailShuffleButton.apply { binding.detailShuffleButton.apply {
isEnabled = playlist.songs.isNotEmpty() isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
setOnClickListener { listener.onShuffle() } setOnClickListener { listener.onShuffle() }
} }
} }

View file

@ -278,10 +278,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
override fun updateEditing(editing: Boolean) { override fun updateEditing(editing: Boolean) {
binding.songDragHandle.isInvisible = !editing binding.songDragHandle.isInvisible = !editing
binding.songMenu.isInvisible = editing binding.songMenu.isInvisible = editing
binding.interactBody.apply { binding.interactBody.isEnabled = !editing
isClickable = !editing
isFocusable = !editing
}
} }
companion object { companion object {

View file

@ -103,42 +103,47 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* *
* @param album the [Album] to bind. * @param album the [Album] to bind.
*/ */
fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover) fun bind(album: Album) = bind(album, R.drawable.ic_album_24, R.string.desc_album_cover)
/** /**
* Bind an [Artist]'s image to this view, also updating the content description. * Bind an [Artist]'s image to this view, also updating the content description.
* *
* @param artist the [Artist] to bind. * @param artist the [Artist] to bind.
*/ */
fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image) fun bind(artist: Artist) = bind(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
/** /**
* Bind an [Genre]'s image to this view, also updating the content description. * Bind an [Genre]'s image to this view, also updating the content description.
* *
* @param genre the [Genre] to bind. * @param genre the [Genre] to bind.
*/ */
fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) fun bind(genre: Genre) = bind(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
/** /**
* Bind a [Playlist]'s image to this view, also updating the content description. * Bind a [Playlist]'s image to this view, also updating the content description.
* *
* @param playlist the [Playlist] to bind. * @param playlist The [Playlist] to bind.
* @param songs [Song]s that can override the playlist image if it needs to differ for any
* reason.
*/ */
fun bind(playlist: Playlist) = fun bind(playlist: Playlist, songs: List<Song>? = null) =
bindImpl(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image) if (songs != null) {
bind(
songs,
context.getString(R.string.desc_playlist_image, playlist.name.resolve(context)),
R.drawable.ic_playlist_24)
} else {
bind(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image)
}
/** private fun bind(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
* Internally bind a [Music]'s image to this view. bind(parent.songs, context.getString(descRes, parent.name.resolve(context)), errorRes)
* }
* @param parent The music to bind, in the form of it's [MusicParent]s.
* @param errorRes The error drawable resource to use if the music cannot be loaded. private fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) {
* @param descRes The content description string resource to use. The resource must have one
* field for the name of the [Music].
*/
private fun bindImpl(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
val request = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(parent.songs) .data(songs)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes))) .error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
.transformations(SquareFrameTransform.INSTANCE) .transformations(SquareFrameTransform.INSTANCE)
.target(this) .target(this)
@ -146,8 +151,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Dispose of any previous image request and load a new image. // Dispose of any previous image request and load a new image.
CoilUtils.dispose(this) CoilUtils.dispose(this)
imageLoader.enqueue(request) imageLoader.enqueue(request)
// Update the content description to the specified resource. contentDescription = desc
contentDescription = context.getString(descRes, parent.name.resolve(context))
} }
/** /**

View file

@ -7,5 +7,5 @@
android:tint="?attr/colorControlNormal"> android:tint="?attr/colorControlNormal">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L658,133Q681,110 714.5,110Q748,110 771,133L827,189Q850,212 851,244.5Q852,277 829,300L772,357ZM714,416L290,840L120,840L120,670L544,246L714,416ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/> android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L715,76L884,245L772,357ZM120,840L120,670L544,246L714,416L290,840L120,840ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/>
</vector> </vector>