detail: make playlist view use collapsing toolbar

This commit is contained in:
Alexander Capehart 2024-07-20 13:13:56 -06:00
parent 6ea7233626
commit d909f2d98e
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 68 additions and 283 deletions

View file

@ -683,7 +683,6 @@ constructor(
val songs = editedPlaylist.value ?: playlist.songs val songs = editedPlaylist.value ?: playlist.songs
if (songs.isNotEmpty()) { if (songs.isNotEmpty()) {
val header = EditHeader(R.string.lbl_songs) val header = EditHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header) list.add(header)
list.addAll(songs) list.addAll(songs)
} }

View file

@ -19,27 +19,21 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetail2Binding
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDragCallback import org.oxycblt.auxio.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
@ -54,14 +48,15 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -72,9 +67,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class PlaylistDetailFragment : class PlaylistDetailFragment :
ListFragment<Song, FragmentDetailBinding>(), DetailFragment<Playlist, Song>(), PlaylistDetailListAdapter.Listener {
DetailHeaderAdapter.Listener,
PlaylistDetailListAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels() override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
@ -82,7 +75,6 @@ class PlaylistDetailFragment :
// Information about what playlist to display is initially within the navigation arguments // Information about what playlist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an playlist. // as a UID, as that is the only safe way to parcel an playlist.
private val args: PlaylistDetailFragmentArgs by navArgs() private val args: PlaylistDetailFragmentArgs by navArgs()
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this) private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null private var touchHelper: ItemTouchHelper? = null
private var editNavigationListener: DialogAwareNavigationListener? = null private var editNavigationListener: DialogAwareNavigationListener? = null
@ -97,12 +89,9 @@ class PlaylistDetailFragment :
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false) reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
} }
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) override fun getDetailListAdapter() = playlistListAdapter
override fun getSelectionToolbar(binding: FragmentDetailBinding) = override fun onBindingCreated(binding: FragmentDetail2Binding, savedInstanceState: Bundle?) {
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit) editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit)
@ -119,14 +108,6 @@ class PlaylistDetailFragment :
} }
// --- UI SETUP --- // --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailEditToolbar.apply { binding.detailEditToolbar.apply {
setNavigationOnClickListener { detailModel.dropPlaylistEdit() } setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
@ -134,28 +115,18 @@ class PlaylistDetailFragment :
} }
binding.detailRecycler.apply { binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) adapter = playlistListAdapter
touchHelper = touchHelper =
ItemTouchHelper(PlaylistDragCallback(detailModel)).also { ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
it.attachToRecyclerView(this) it.attachToRecyclerView(this)
} }
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.playlistSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setPlaylist(args.playlistUid) detailModel.setPlaylist(args.playlistUid)
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist) collectImmediately(
detailModel.currentPlaylist, detailModel.editedPlaylist, ::updatePlaylist)
collectImmediately(detailModel.playlistSongList, ::updateList) collectImmediately(detailModel.playlistSongList, ::updateList)
collectImmediately(detailModel.editedPlaylist, ::updateEditedList) collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
@ -195,7 +166,7 @@ class PlaylistDetailFragment :
.release(findNavController()) .release(findNavController())
} }
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetail2Binding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailNormalToolbar.setOnMenuItemClickListener(null) binding.detailNormalToolbar.setOnMenuItemClickListener(null)
touchHelper = null touchHelper = null
@ -210,41 +181,82 @@ class PlaylistDetailFragment :
playbackModel.play(item, detailModel.playInPlaylistWith) playbackModel.play(item, detailModel.playInPlaylistWith)
} }
override fun onStartEdit() {
detailModel.startPlaylistEdit()
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder) requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
} }
override fun onOpenParentMenu() {
listModel.openMenu(R.menu.playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onOpenMenu(item: Song) { override fun onOpenMenu(item: Song) {
listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith) listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith)
} }
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onStartEdit() {
detailModel.startPlaylistEdit()
}
override fun onOpenSortMenu() { override fun onOpenSortMenu() {
findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort()) findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort())
} }
private fun updatePlaylist(playlist: Playlist?) { private fun updatePlaylist(playlist: Playlist?, editedPlaylist: List<Song>?) {
if (playlist == null) { if (playlist == null) {
// Playlist we were showing no longer exists. // Playlist we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
val binding = requireBinding() val binding = requireBinding()
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext()) binding.detailToolbarTitle.text = playlist.name.resolve(requireContext())
binding.detailEditToolbar.title = binding.detailEditToolbar.title =
getString(R.string.fmt_editing, playlist.name.resolve(requireContext())) getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
playlistHeaderAdapter.setParent(playlist)
if (editedPlaylist != null) {
logD("Binding edited playlist image")
binding.detailCover.bind(
editedPlaylist,
binding.context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
} else {
binding.detailCover.bind(playlist)
}
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
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.
binding.detailInfo.text =
if (songs.isNotEmpty()) {
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
durationMs.formatDurationMs(true))
} else {
binding.context.getString(R.string.def_song_count)
}
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
if (!playable) {
logD("Playlist is being edited or is empty, disabling playback options")
}
binding.detailPlayButton.apply {
isEnabled = playable
setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailShuffleButton.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
} }
private fun updateList(list: List<Item>) { private fun updateList(list: List<Item>) {
@ -253,7 +265,6 @@ class PlaylistDetailFragment :
private fun updateEditedList(editedPlaylist: List<Song>?) { private fun updateEditedList(editedPlaylist: List<Song>?) {
playlistListAdapter.setEditing(editedPlaylist != null) playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
listModel.dropSelection() listModel.dropSelection()
if (editedPlaylist != null) { if (editedPlaylist != null) {

View file

@ -1,84 +0,0 @@
/*
* 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
import org.oxycblt.auxio.util.logD
/**
* 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) {
logD("Updating parent [old: $currentParent new: $parent]")
currentParent = parent
rebindParent()
}
/**
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
*/
protected fun rebindParent() {
logD("Rebinding parent")
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
}
/** A 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

@ -1,141 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailHeaderAdapter.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.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [DetailHeaderAdapter] that shows [Playlist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
private var editedPlaylist: List<Song>? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
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
}
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
editedPlaylist = songs
rebindParent()
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Playlist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @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.
*/
fun bind(
playlist: Playlist,
editedPlaylist: List<Song>?,
listener: DetailHeaderAdapter.Listener
) {
if (editedPlaylist != null) {
logD("Binding edited playlist image")
binding.detailCover.bind(
editedPlaylist,
binding.context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
} else {
binding.detailCover.bind(playlist)
}
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
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.
binding.detailInfo.text =
if (songs.isNotEmpty()) {
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
durationMs.formatDurationMs(true))
} else {
binding.context.getString(R.string.def_song_count)
}
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
if (!playable) {
logD("Playlist is being edited or is empty, disabling playback options")
}
binding.detailPlayButton.apply {
isEnabled = playable
setOnClickListener { listener.onPlay() }
}
binding.detailShuffleButton.apply {
isEnabled = playable
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) =
PlaylistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}