diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 452b050cd..090c838a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -683,7 +683,6 @@ constructor( val songs = editedPlaylist.value ?: playlist.songs if (songs.isNotEmpty()) { val header = EditHeader(R.string.lbl_songs) - list.add(Divider(header)) list.add(header) list.addAll(songs) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index dce61f05c..6693e3bfd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -19,27 +19,21 @@ package org.oxycblt.auxio.detail import android.os.Bundle -import android.view.LayoutInflater import android.view.MenuItem import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView 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.header.DetailHeaderAdapter -import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter +import org.oxycblt.auxio.databinding.FragmentDetail2Binding import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter 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.ListFragment 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.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.util.collect 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.logW 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.unlikelyToBeNull @@ -72,9 +67,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull */ @AndroidEntryPoint class PlaylistDetailFragment : - ListFragment(), - DetailHeaderAdapter.Listener, - PlaylistDetailListAdapter.Listener { + DetailFragment(), PlaylistDetailListAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() override val listModel: ListViewModel 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 // as a UID, as that is the only safe way to parcel an playlist. private val args: PlaylistDetailFragmentArgs by navArgs() - private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this) private val playlistListAdapter = PlaylistDetailListAdapter(this) private var touchHelper: ItemTouchHelper? = null private var editNavigationListener: DialogAwareNavigationListener? = null @@ -97,12 +89,9 @@ class PlaylistDetailFragment : reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false) } - override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) + override fun getDetailListAdapter() = playlistListAdapter - override fun getSelectionToolbar(binding: FragmentDetailBinding) = - binding.detailSelectionToolbar - - override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: FragmentDetail2Binding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit) @@ -119,14 +108,6 @@ class PlaylistDetailFragment : } // --- 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 { setNavigationOnClickListener { detailModel.dropPlaylistEdit() } @@ -134,28 +115,18 @@ class PlaylistDetailFragment : } binding.detailRecycler.apply { - adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) + adapter = playlistListAdapter touchHelper = ItemTouchHelper(PlaylistDragCallback(detailModel)).also { 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 --- // DetailViewModel handles most initialization from the navigation argument. detailModel.setPlaylist(args.playlistUid) - collectImmediately(detailModel.currentPlaylist, ::updatePlaylist) + collectImmediately( + detailModel.currentPlaylist, detailModel.editedPlaylist, ::updatePlaylist) collectImmediately(detailModel.playlistSongList, ::updateList) collectImmediately(detailModel.editedPlaylist, ::updateEditedList) collect(detailModel.toShow.flow, ::handleShow) @@ -195,7 +166,7 @@ class PlaylistDetailFragment : .release(findNavController()) } - override fun onDestroyBinding(binding: FragmentDetailBinding) { + override fun onDestroyBinding(binding: FragmentDetail2Binding) { super.onDestroyBinding(binding) binding.detailNormalToolbar.setOnMenuItemClickListener(null) touchHelper = null @@ -210,41 +181,82 @@ class PlaylistDetailFragment : playbackModel.play(item, detailModel.playInPlaylistWith) } + override fun onStartEdit() { + detailModel.startPlaylistEdit() + } + override fun onPickUp(viewHolder: RecyclerView.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) { 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() { findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort()) } - private fun updatePlaylist(playlist: Playlist?) { + private fun updatePlaylist(playlist: Playlist?, editedPlaylist: List?) { if (playlist == null) { // Playlist we were showing no longer exists. findNavController().navigateUp() return } val binding = requireBinding() - binding.detailNormalToolbar.title = playlist.name.resolve(requireContext()) + binding.detailToolbarTitle.text = playlist.name.resolve(requireContext()) binding.detailEditToolbar.title = 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) { @@ -253,7 +265,6 @@ class PlaylistDetailFragment : private fun updateEditedList(editedPlaylist: List?) { playlistListAdapter.setEditing(editedPlaylist != null) - playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) listModel.dropSelection() if (editedPlaylist != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt deleted file mode 100644 index 4afabb6c3..000000000 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt +++ /dev/null @@ -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 . - */ - -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 : - RecyclerView.Adapter() { - 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() - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt deleted file mode 100644 index 67f3b82ec..000000000 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt +++ /dev/null @@ -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 . - */ - -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() { - private var editedPlaylist: List? = 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?) { - 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?, - 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)) - } -}