diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fd1f7d1..d9a774917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ be parsed as images - Fixed issue where searches would match song file names case-sensitively - Fixed issue where the notification would not respond to changes in the album cover setting - Fixed issue where short names starting with an article would not be correctly sorted (ex. "the 1") +- Fixed incorrect item arrangement on landscape +- Fixed disappearing dividers in search view +- Reduced likelihood that images (eg. album covers) would not update when the music library changed #### Dev/Meta - Switched to androidx media3 (New Home of ExoPlayer) for backing player components diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 3e9a639c3..93b8b239a 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -37,16 +37,18 @@ object IntegerTable { const val VIEW_TYPE_PLAYLIST = 0xA004 /** BasicHeaderViewHolder */ const val VIEW_TYPE_BASIC_HEADER = 0xA005 + /** DividerViewHolder */ + const val VIEW_TYPE_DIVIDER = 0xA006 /** SortHeaderViewHolder */ - const val VIEW_TYPE_SORT_HEADER = 0xA006 + const val VIEW_TYPE_SORT_HEADER = 0xA007 /** AlbumSongViewHolder */ - const val VIEW_TYPE_ALBUM_SONG = 0xA007 + const val VIEW_TYPE_ALBUM_SONG = 0xA008 /** ArtistAlbumViewHolder */ - const val VIEW_TYPE_ARTIST_ALBUM = 0xA008 + const val VIEW_TYPE_ARTIST_ALBUM = 0xA009 /** ArtistSongViewHolder */ - const val VIEW_TYPE_ARTIST_SONG = 0xA009 + const val VIEW_TYPE_ARTIST_SONG = 0xA00A /** DiscHeaderViewHolder */ - const val VIEW_TYPE_DISC_HEADER = 0xA00A + const val VIEW_TYPE_DISC_HEADER = 0xA00B /** "Music playback" notification code */ const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 /** "Music loading" notification code */ diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 106ba2885..90ada8b9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -136,8 +136,9 @@ class MainFragment : collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) - collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) + collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist) collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist) + collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) collectImmediately(playbackModel.song, ::updateSong) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) @@ -315,12 +316,11 @@ class MainFragment : } } - private fun handleAddToPlaylist(songs: List?) { - if (songs != null) { + private fun handleRenamePlaylist(playlist: Playlist?) { + if (playlist != null) { findNavController() - .navigateSafe( - MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray())) - musicModel.songsToAdd.consume() + .navigateSafe(MainFragmentDirections.actionRenamePlaylist(playlist.uid)) + musicModel.playlistToRename.consume() } } @@ -331,6 +331,16 @@ class MainFragment : musicModel.playlistToDelete.consume() } } + + private fun handleAddToPlaylist(songs: List?) { + if (songs != null) { + findNavController() + .navigateSafe( + MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray())) + musicModel.songsToAdd.consume() + } + } + private fun handlePlaybackArtistPicker(song: Song?) { if (song != null) { navModel.mainNavigateTo( diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 7ed832468..7a7ecf00e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -26,6 +26,7 @@ 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.LinearSmoothScroller import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint @@ -34,6 +35,8 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding 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.Divider +import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort @@ -45,6 +48,7 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.* @@ -95,7 +99,17 @@ class AlbumDetailFragment : setOnMenuItemClickListener(this@AlbumDetailFragment) } - binding.detailRecycler.adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter) + binding.detailRecycler.apply { + adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter) + (layoutManager as GridLayoutManager).setFullWidthLookup { + if (it != 0) { + val item = detailModel.albumList.value[it - 1] + item is Divider || item is Header || item is Disc + } else { + true + } + } + } // -- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 43e3e6993..55ebb6ff5 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -26,6 +26,7 @@ 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 com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R @@ -34,6 +35,8 @@ 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.Divider +import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort @@ -94,7 +97,17 @@ class ArtistDetailFragment : setOnMenuItemClickListener(this@ArtistDetailFragment) } - binding.detailRecycler.adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter) + binding.detailRecycler.apply { + adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter) + (layoutManager as GridLayoutManager).setFullWidthLookup { + if (it != 0) { + val item = detailModel.artistList.value[it - 1] + item is Divider || item is Header + } else { + true + } + } + } // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. 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 d2e87f6ed..127c84468 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.R import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.list.BasicHeader +import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.UpdateInstructions @@ -297,7 +298,9 @@ constructor( private fun refreshAlbumList(album: Album, replace: Boolean = false) { logD("Refreshing album list") val list = mutableListOf() - list.add(SortHeader(R.string.lbl_songs)) + val header = SortHeader(R.string.lbl_songs) + list.add(Divider(header)) + list.add(header) val instructions = if (replace) { // Intentional so that the header item isn't replaced with the songs @@ -355,7 +358,9 @@ constructor( logD("Release groups for this artist: ${byReleaseGroup.keys}") for (entry in byReleaseGroup.entries.sortedBy { it.key }) { - list.add(BasicHeader(entry.key.headerTitleRes)) + val header = BasicHeader(entry.key.headerTitleRes) + list.add(Divider(header)) + list.add(header) list.addAll(entry.value) } @@ -363,7 +368,9 @@ constructor( var instructions: UpdateInstructions = UpdateInstructions.Diff if (artist.songs.isNotEmpty()) { logD("Songs present in this artist, adding header") - list.add(SortHeader(R.string.lbl_songs)) + val header = SortHeader(R.string.lbl_songs) + list.add(Divider(header)) + list.add(header) if (replace) { // Intentional so that the header item isn't replaced with the songs instructions = UpdateInstructions.Replace(list.size) @@ -379,9 +386,14 @@ constructor( logD("Refreshing genre list") val list = mutableListOf() // Genre is guaranteed to always have artists and songs. - list.add(BasicHeader(R.string.lbl_artists)) + val artistHeader = BasicHeader(R.string.lbl_artists) + list.add(Divider(artistHeader)) + list.add(artistHeader) list.addAll(genre.artists) - list.add(SortHeader(R.string.lbl_songs)) + + val songHeader = SortHeader(R.string.lbl_songs) + list.add(Divider(songHeader)) + list.add(songHeader) val instructions = if (replace) { // Intentional so that the header item isn't replaced with the songs @@ -400,7 +412,9 @@ constructor( val list = mutableListOf() if (playlist.songs.isNotEmpty()) { - list.add(SortHeader(R.string.lbl_songs)) + val header = SortHeader(R.string.lbl_songs) + list.add(Divider(header)) + list.add(header) if (replace) { instructions = UpdateInstructions.Replace(list.size) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 581b2e18d..e094a5a70 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -26,6 +26,7 @@ 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 com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R @@ -34,6 +35,8 @@ 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.Divider +import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort @@ -87,7 +90,17 @@ class GenreDetailFragment : setOnMenuItemClickListener(this@GenreDetailFragment) } - binding.detailRecycler.adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter) + binding.detailRecycler.apply { + adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter) + (layoutManager as GridLayoutManager).setFullWidthLookup { + if (it != 0) { + val item = detailModel.genreList.value[it - 1] + item is Divider || item is Header + } else { + true + } + } + } // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. 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 b1ef26f3e..86eba91bb 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -26,6 +26,7 @@ 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 com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R @@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.DetailHeaderAdapter import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter +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.Sort @@ -87,7 +90,17 @@ class PlaylistDetailFragment : setOnMenuItemClickListener(this@PlaylistDetailFragment) } - binding.detailRecycler.adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) + binding.detailRecycler.apply { + adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) + (layoutManager as GridLayoutManager).setFullWidthLookup { + if (it != 0) { + val item = detailModel.playlistList.value[it - 1] + item is Divider || item is Header + } else { + true + } + } + } // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. @@ -126,6 +139,10 @@ class PlaylistDetailFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_rename -> { + musicModel.renamePlaylist(currentPlaylist) + true + } R.id.action_delete -> { musicModel.deletePlaylist(currentPlaylist) true diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index b3d01e970..b7217a681 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -69,15 +69,6 @@ class AlbumDetailListAdapter(private val listener: Listener) : } } - override fun isItemFullWidth(position: Int): Boolean { - if (super.isItemFullWidth(position)) { - return true - } - // The album and disc headers should be full-width in all configurations. - val item = getItem(position) - return item is Album || item is Disc - } - private companion object { /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt index f27a11100..e281d9982 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt @@ -65,14 +65,6 @@ class ArtistDetailListAdapter(private val listener: Listener) : } } - override fun isItemFullWidth(position: Int): Boolean { - if (super.isItemFullWidth(position)) { - return true - } - // Artist headers should be full-width in all configurations. - return getItem(position) is Artist - } - private companion object { /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt index 7959ec47d..cd23751be 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt @@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.databinding.ItemSortHeaderBinding import org.oxycblt.auxio.list.BasicHeader +import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener @@ -47,13 +48,12 @@ import org.oxycblt.auxio.util.inflater abstract class DetailListAdapter( private val listener: Listener<*>, private val diffCallback: DiffUtil.ItemCallback -) : - SelectionIndicatorAdapter(diffCallback), - AuxioRecyclerView.SpanSizeLookup { +) : SelectionIndicatorAdapter(diffCallback) { override fun getItemViewType(position: Int) = when (getItem(position)) { // Implement support for headers and sort headers + is Divider -> DividerViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE else -> super.getItemViewType(position) @@ -61,6 +61,7 @@ abstract class DetailListAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { + DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent) else -> error("Invalid item type $viewType") @@ -73,12 +74,6 @@ abstract class DetailListAdapter( } } - override fun isItemFullWidth(position: Int): Boolean { - // Headers should be full-width in all configurations. - val item = getItem(position) - return item is BasicHeader || item is SortHeader - } - /** An extended [SelectableListListener] for [DetailListAdapter] implementations. */ interface Listener : SelectableListListener { /** @@ -94,6 +89,8 @@ abstract class DetailListAdapter( object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { + oldItem is Divider && newItem is Divider -> + DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is BasicHeader && newItem is BasicHeader -> BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is SortHeader && newItem is SortHeader -> diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt index e4a33c80b..5f2c704f2 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt @@ -60,14 +60,6 @@ class GenreDetailListAdapter(private val listener: 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() { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt index a6c695ac2..5a33e511f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -56,14 +56,6 @@ class PlaylistDetailListAdapter(private val listener: Listener) : } } - override fun isItemFullWidth(position: Int): Boolean { - if (super.isItemFullWidth(position)) { - return true - } - // Playlist headers should be full-width in all configurations - return getItem(position) is Playlist - } - companion object { val DIFF_CALLBACK = object : SimpleDiffCallback() { diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 3f0ecfb38..12ef10a50 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -36,12 +36,11 @@ import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* class SongKeyer @Inject constructor() : Keyer { - override fun key(data: Song, options: Options) = - "${data.album.uid}${data.album.songs.hashCode()}" + override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}" } class ParentKeyer @Inject constructor() : Keyer { - override fun key(data: MusicParent, options: Options) = "${data.uid}${data.songs.hashCode()}" + override fun key(data: MusicParent, options: Options) = "${data.uid}${data.hashCode()}" } /** diff --git a/app/src/main/java/org/oxycblt/auxio/list/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt index 5fed1627d..e41dd4149 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Data.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt @@ -40,3 +40,11 @@ interface Header : Item { * @author Alexander Capehart (OxygenCobalt) */ data class BasicHeader(@StringRes override val titleRes: Int) : Header + +/** + * A divider decoration used to delimit groups of data. + * + * @param anchor The [Header] this divider should be next to in a list. Used as a way to preserve + * divider continuity during list updates. + */ +data class Divider(val anchor: Header?) : Item diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt index c9fca7b0e..ab31dad78 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -268,6 +268,9 @@ abstract class ListFragment : playbackModel.addToQueue(playlist) requireContext().showToast(R.string.lng_queue_added) } + R.id.action_rename -> { + musicModel.renamePlaylist(playlist) + } R.id.action_delete -> { musicModel.deletePlaylist(playlist) } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt index b535feba2..1c5923b3c 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt @@ -23,18 +23,14 @@ import android.util.AttributeSet import android.view.WindowInsets import androidx.annotation.AttrRes import androidx.core.view.updatePadding -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.util.systemBarInsetsCompat /** * A [RecyclerView] with a few QoL extensions, such as: * - Automatic edge-to-edge support - * - Adapter-based [SpanSizeLookup] implementation * - Automatic [setHasFixedSize] setup * - * FIXME: Broken span configuration - * * @author Alexander Capehart (OxygenCobalt) */ open class AuxioRecyclerView @@ -49,7 +45,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr // Auxio's non-dialog RecyclerViews never change their size based on adapter contents, // so we can enable fixed-size optimizations. setHasFixedSize(true) - addItemDecoration(HeaderItemDecoration(context)) } final override fun setHasFixedSize(hasFixedSize: Boolean) { @@ -67,36 +62,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom) return insets } - - override fun setAdapter(adapter: Adapter<*>?) { - super.setAdapter(adapter) - - if (adapter is SpanSizeLookup) { - // This adapter has support for special span sizes, hook it up to the - // GridLayoutManager. - val glm = (layoutManager as GridLayoutManager) - val fullWidthSpanCount = glm.spanCount - glm.spanSizeLookup = - object : GridLayoutManager.SpanSizeLookup() { - // Using the adapter implementation, if the adapter specifies that - // an item is full width, it will take up all of the spans, using a - // single span otherwise. - override fun getSpanSize(position: Int) = - if (adapter.isItemFullWidth(position)) fullWidthSpanCount else 1 - } - } - } - - /** A [RecyclerView.Adapter]-specific hook to control divider decoration visibility. */ - - /** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */ - interface SpanSizeLookup { - /** - * Get if the item at a position takes up the whole width of the [RecyclerView] or not. - * - * @param position The position of the item. - * @return true if the item is full-width, false otherwise. - */ - fun isItemFullWidth(position: Int): Boolean - } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt deleted file mode 100644 index 1ffc6c1fc..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * HeaderItemDecoration.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.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 -import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.Header -import org.oxycblt.auxio.list.adapter.FlexibleListAdapter - -/** - * A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly - * separate content with headers. - * - * @author Alexander Capehart (OxygenCobalt) - */ -class HeaderItemDecoration -@JvmOverloads -constructor( - context: Context, - attributeSet: AttributeSet? = null, - defStyleAttr: Int = R.attr.materialDividerStyle, - orientation: Int = LinearLayoutManager.VERTICAL -) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) { - 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) is Header - } catch (e: ClassCastException) { - false - } catch (e: IndexOutOfBoundsException) { - false - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 7119a163c..0c9962996 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -20,12 +20,14 @@ package org.oxycblt.auxio.list.recycler import android.view.View import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.MaterialDivider import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.list.BasicHeader +import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback @@ -246,7 +248,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = object : SimpleDiffCallback() { - override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = + override fun areContentsTheSame(oldItem: Genre, newItem: Genre) = oldItem.name == newItem.name && oldItem.artists.size == newItem.artists.size && oldItem.songs.size == newItem.songs.size @@ -304,7 +306,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = object : SimpleDiffCallback() { - override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist): Boolean = + override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist) = oldItem.name == newItem.name && oldItem.songs.size == newItem.songs.size } } @@ -343,10 +345,37 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = object : SimpleDiffCallback() { - override fun areContentsTheSame( - oldItem: BasicHeader, - newItem: BasicHeader - ): Boolean = oldItem.titleRes == newItem.titleRes + override fun areContentsTheSame(oldItem: BasicHeader, newItem: BasicHeader) = + oldItem.titleRes == newItem.titleRes + } + } +} + +/** + * A [RecyclerView.ViewHolder] that displays a [Divider]. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class DividerViewHolder private constructor(divider: MaterialDivider) : + RecyclerView.ViewHolder(divider) { + + companion object { + /** Unique ID for this ViewHolder type. */ + const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DIVIDER + + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = DividerViewHolder(MaterialDivider(parent.context)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: Divider, newItem: Divider) = + oldItem.anchor == newItem.anchor } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 37cbf83bc..bcf2fb53e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -118,6 +118,16 @@ sealed interface Music : Item { } companion object { + /** + * Creates an Auxio-style [UID] of random composition. Used if there is no + * non-subjective, unlikely-to-change metadata of the music. + * + * @param mode The analogous [MusicMode] of the item that created this [UID]. + */ + fun auxio(mode: MusicMode): UID { + return UID(Format.AUXIO, mode, UUID.randomUUID()) + } + /** * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective, * unlikely-to-change metadata of the music. diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 99e93ffb0..91ad069fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -118,6 +118,14 @@ interface MusicRepository { */ fun createPlaylist(name: String, songs: List) + /** + * Rename a [Playlist]. + * + * @param playlist The [Playlist] to rename. + * @param name The name of the new [Playlist]. + */ + fun renamePlaylist(playlist: Playlist, name: String) + /** * Delete a [Playlist]. * @@ -269,6 +277,15 @@ constructor( } } + override fun renamePlaylist(playlist: Playlist, name: String) { + val userLibrary = userLibrary ?: return + userLibrary.renamePlaylist(playlist, name) + for (listener in updateListeners) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) + } + } + override fun deletePlaylist(playlist: Playlist) { val userLibrary = userLibrary ?: return userLibrary.deletePlaylist(playlist) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 75527b6d8..873ed851e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -52,15 +52,20 @@ constructor( /** Flag for opening a dialog to create a playlist of the given [Song]s. */ val newPlaylistSongs: Event> = _newPlaylistSongs - private val _songsToAdd = MutableEvent>() - /** Flag for opening a dialog to add the given [Song]s to a playlist. */ - val songsToAdd: Event> = _songsToAdd + private val _playlistToRename = MutableEvent() + /** Flag for opening a dialog to rename the given [Playlist]. */ + val playlistToRename: Event + get() = _playlistToRename private val _playlistToDelete = MutableEvent() /** Flag for opening a dialog to confirm deletion of the given [Playlist]. */ val playlistToDelete: Event get() = _playlistToDelete + private val _songsToAdd = MutableEvent>() + /** Flag for opening a dialog to add the given [Song]s to a playlist. */ + val songsToAdd: Event> = _songsToAdd + init { musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -111,6 +116,20 @@ constructor( } } + /** + * Rename the given playlist. + * + * @param playlist The [Playlist] to rename, + * @param name The new name of the [Playlist]. If null, the user will be prompted for a name. + */ + fun renamePlaylist(playlist: Playlist, name: String? = null) { + if (name != null) { + musicRepository.renamePlaylist(playlist, name) + } else { + _playlistToRename.put(playlist) + } + } + /** * Delete a [Playlist]. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index 66660bb6d..1cdb8b4db 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -73,7 +73,7 @@ class AddToPlaylistDialog : // --- VIEWMODEL SETUP --- pickerModel.setSongsToAdd(args.songUids) collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) - collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices) + collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices) } override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt index cada8ed00..afc90c825 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -54,6 +55,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment(null) + /** A new [Playlist] having it's name chosen by the user. Null if none yet. */ val currentPendingPlaylist: StateFlow get() = _currentPendingPlaylist + private val _currentPlaylistToRename = MutableStateFlow(null) + /** An existing [Playlist] that is being renamed. Null if none yet. */ + val currentPlaylistToRename: StateFlow + get() = _currentPlaylistToRename + + private val _currentPlaylistToDelete = MutableStateFlow(null) + /** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */ + val currentPlaylistToDelete: StateFlow + get() = _currentPlaylistToDelete + private val _chosenName = MutableStateFlow(ChosenName.Empty) + /** The users chosen name for [currentPendingPlaylist] or [currentPlaylistToRename]. */ val chosenName: StateFlow get() = _chosenName private val _currentSongsToAdd = MutableStateFlow?>(null) + /** A batch of [Song]s to add to a playlist chosen by the user. Null if none yet. */ val currentSongsToAdd: StateFlow?> get() = _currentSongsToAdd - private val _playlistChoices = MutableStateFlow>(listOf()) - val playlistChoices: StateFlow> - get() = _playlistChoices - - private val _currentPlaylistToDelete = MutableStateFlow(null) - val currentPlaylistToDelete: StateFlow - get() = _currentPlaylistToDelete + private val _playlistAddChoices = MutableStateFlow>(listOf()) + /** The [Playlist]s that [currentSongsToAdd] could be added to. */ + val playlistAddChoices: StateFlow> + get() = _playlistAddChoices init { musicRepository.addUpdateListener(this) @@ -124,6 +134,24 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } } + /** + * Set a new [currentPlaylistToRename] from a [Playlist] [Music.UID]. + * + * @param playlistUid The [Music.UID]s of the [Playlist] to rename. + */ + fun setPlaylistToRename(playlistUid: Music.UID) { + _currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid) + } + + /** + * Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID]. + * + * @param playlistUid The [Music.UID] of the [Playlist] to delete. + */ + fun setPlaylistToDelete(playlistUid: Music.UID) { + _currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid) + } + /** * Update the current [chosenName] based on new user input. * @@ -160,21 +188,12 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M private fun refreshPlaylistChoices(songs: List) { val userLibrary = musicRepository.userLibrary ?: return - _playlistChoices.value = + _playlistAddChoices.value = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { val songSet = it.songs.toSet() PlaylistChoice(it, songs.all(songSet::contains)) } } - - /** - * Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID]. - * - * @param playlistUid The [Music.UID] of the [Playlist] to delete. - */ - fun setPlaylistToDelete(playlistUid: Music.UID) { - _currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid) - } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt new file mode 100644 index 000000000..d3fb58323 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 Auxio Project + * RenamePlaylistDialog.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.music.picker + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * A dialog allowing the name of a new playlist to be chosen before committing it to the database. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class RenamePlaylistDialog : ViewBindingDialogFragment() { + private val musicModel: MusicViewModel by activityViewModels() + private val pickerModel: PlaylistPickerViewModel by viewModels() + // Information about what playlist to name for is initially within the navigation arguments + // as UIDs, as that is the only safe way to parcel playlist information. + private val args: RenamePlaylistDialogArgs by navArgs() + private var initializedField = false + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_rename) + .setPositiveButton(R.string.lbl_ok) { _, _ -> + val playlist = unlikelyToBeNull(pickerModel.currentPlaylistToRename.value) + val chosenName = pickerModel.chosenName.value as ChosenName.Valid + musicModel.renamePlaylist(playlist, chosenName.value) + requireContext().showToast(R.string.lng_playlist_renamed) + findNavController().navigateUp() + } + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogPlaylistNameBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- UI SETUP --- + binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) } + + // --- VIEWMODEL SETUP --- + pickerModel.setPlaylistToRename(args.playlistUid) + collectImmediately(pickerModel.currentPlaylistToRename, ::updatePlaylistToRename) + collectImmediately(pickerModel.chosenName, ::updateChosenName) + } + + private fun updatePlaylistToRename(playlist: Playlist?) { + if (playlist == null) { + // Nothing to rename anymore. + findNavController().navigateUp() + return + } + + if (!initializedField) { + requireBinding().playlistName.setText(playlist.name.resolve(requireContext())) + initializedField = true + } + } + + private fun updateChosenName(chosenName: ChosenName) { + (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = + chosenName is ChosenName.Valid + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index afd89de1d..00127a846 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -21,7 +21,6 @@ package org.oxycblt.auxio.music.user import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.info.Name -import org.oxycblt.auxio.util.update class PlaylistImpl private constructor( @@ -33,6 +32,15 @@ private constructor( override val albums = songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } + /** + * Clone the data in this instance to a new [PlaylistImpl] with the given [name]. + * + * @param name The new name to use. + * @param musicSettings [MusicSettings] required for name configuration. + */ + fun edit(name: String, musicSettings: MusicSettings) = + PlaylistImpl(uid, Name.Known.from(name, null, musicSettings), songs) + /** * Clone the data in this instance to a new [PlaylistImpl] with the given [Song]s. * @@ -48,9 +56,14 @@ private constructor( inline fun edit(edits: MutableList.() -> Unit) = edit(songs.toMutableList().apply(edits)) override fun equals(other: Any?) = - other is PlaylistImpl && uid == other.uid && songs == other.songs + other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs - override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() + override fun hashCode(): Int { + var hashCode = uid.hashCode() + hashCode = 31 * hashCode + name.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + return hashCode + } companion object { /** @@ -62,7 +75,7 @@ private constructor( */ fun from(name: String, songs: List, musicSettings: MusicSettings) = PlaylistImpl( - Music.UID.auxio(MusicMode.PLAYLISTS) { update(name) }, + Music.UID.auxio(MusicMode.PLAYLISTS), Name.Known.from(name, null, musicSettings), songs) diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index 7ea2c05b5..563f99316 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -80,6 +80,14 @@ interface MutableUserLibrary : UserLibrary { */ fun createPlaylist(name: String, songs: List) + /** + * Rename a [Playlist]. + * + * @param playlist The [Playlist] to rename. + * @param name The name of the new [Playlist]. + */ + fun renamePlaylist(playlist: Playlist, name: String) + /** * Delete a [Playlist]. * @@ -122,6 +130,13 @@ private class UserLibraryImpl( playlistMap[playlistImpl.uid] = playlistImpl } + @Synchronized + override fun renamePlaylist(playlist: Playlist, name: String) { + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } + playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) + } + @Synchronized override fun deletePlaylist(playlist: Playlist) { playlistMap.remove(playlist.uid) diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 191529cca..4c1b2c2a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -34,8 +34,7 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class SearchAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter(DIFF_CALLBACK), - AuxioRecyclerView.SpanSizeLookup { + SelectionIndicatorAdapter(DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (getItem(position)) { @@ -44,6 +43,7 @@ class SearchAdapter(private val listener: SelectableListListener) : is Artist -> ArtistViewHolder.VIEW_TYPE is Genre -> GenreViewHolder.VIEW_TYPE is Playlist -> PlaylistViewHolder.VIEW_TYPE + is Divider -> DividerViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } @@ -55,6 +55,7 @@ class SearchAdapter(private val listener: SelectableListListener) : ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent) GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent) PlaylistViewHolder.VIEW_TYPE -> PlaylistViewHolder.from(parent) + DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) else -> error("Invalid item type $viewType") } @@ -71,8 +72,6 @@ class SearchAdapter(private val listener: SelectableListListener) : } } - override fun isItemFullWidth(position: Int) = getItem(position) is BasicHeader - private companion object { /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = @@ -87,6 +86,10 @@ class SearchAdapter(private val listener: SelectableListListener) : ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Genre && newItem is Genre -> GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + oldItem is Playlist && newItem is Playlist -> + PlaylistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + oldItem is Divider && newItem is Divider -> + DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is BasicHeader && newItem is BasicHeader -> BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> false diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 7846710b9..b0a0feb06 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -29,10 +29,13 @@ import androidx.core.widget.addTextChangedListener import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding +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.selection.SelectionViewModel @@ -104,7 +107,13 @@ class SearchFragment : ListFragment() { } } - binding.searchRecycler.adapter = searchAdapter + binding.searchRecycler.apply { + adapter = searchAdapter + (layoutManager as GridLayoutManager).setFullWidthLookup { + val item = searchModel.searchResults.value[it] + item is Divider || item is Header + } + } // --- VIEWMODEL SETUP --- diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index e34dcf800..ec42ca3cb 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.oxycblt.auxio.R import org.oxycblt.auxio.list.BasicHeader +import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* @@ -138,23 +139,44 @@ constructor( return buildList { results.artists?.let { - add(BasicHeader(R.string.lbl_artists)) + val header = BasicHeader(R.string.lbl_artists) + add(header) addAll(SORT.artists(it)) } results.albums?.let { - add(BasicHeader(R.string.lbl_albums)) + val header = BasicHeader(R.string.lbl_albums) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) addAll(SORT.albums(it)) } results.playlists?.let { - add(BasicHeader(R.string.lbl_playlists)) + val header = BasicHeader(R.string.lbl_playlists) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) addAll(SORT.playlists(it)) } results.genres?.let { - add(BasicHeader(R.string.lbl_genres)) + val header = BasicHeader(R.string.lbl_genres) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) addAll(SORT.genres(it)) } results.songs?.let { - add(BasicHeader(R.string.lbl_songs)) + val header = BasicHeader(R.string.lbl_songs) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) addAll(SORT.songs(it)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index abee5c248..b70c6051a 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -32,6 +32,7 @@ import androidx.core.graphics.Insets import androidx.core.graphics.drawable.DrawableCompat import androidx.navigation.NavController import androidx.navigation.NavDirections +import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import java.lang.IllegalArgumentException @@ -111,6 +112,20 @@ val ViewBinding.context: Context */ fun RecyclerView.canScroll() = computeVerticalScrollRange() > height +/** + * Shortcut to easily set up a [GridLayoutManager.SpanSizeLookup]. + * + * @param isItemFullWidth Mapping expression that returns true if the item should take up all spans + * or just one. + */ +fun GridLayoutManager.setFullWidthLookup(isItemFullWidth: (Int) -> Boolean) { + spanSizeLookup = + object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int) = + if (isItemFullWidth(position)) spanCount else 1 + } +} + /** * Fix the double ripple that appears in MaterialButton instances due to an issue with AppCompat 1.5 * or higher. diff --git a/app/src/main/res/layout/item_divider.xml b/app/src/main/res/layout/item_divider.xml new file mode 100644 index 000000000..4767eae41 --- /dev/null +++ b/app/src/main/res/layout/item_divider.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_actions.xml b/app/src/main/res/menu/menu_playlist_actions.xml index 93eb12239..6a165da6a 100644 --- a/app/src/main/res/menu/menu_playlist_actions.xml +++ b/app/src/main/res/menu/menu_playlist_actions.xml @@ -13,9 +13,12 @@ android:id="@+id/action_queue_add" android:title="@string/lbl_queue_add" /> + android:id="@+id/action_rename" + android:title="@string/lbl_rename" /> + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_detail.xml b/app/src/main/res/menu/menu_playlist_detail.xml index 761c42dd5..666629234 100644 --- a/app/src/main/res/menu/menu_playlist_detail.xml +++ b/app/src/main/res/menu/menu_playlist_detail.xml @@ -7,9 +7,12 @@ android:id="@+id/action_queue_add" android:title="@string/lbl_queue_add" /> + android:id="@+id/action_rename" + android:title="@string/lbl_rename" /> + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index a11e936ac..54a1a4f37 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -21,11 +21,14 @@ android:id="@+id/action_new_playlist" app:destination="@id/new_playlist_dialog" /> + android:id="@+id/action_rename_playlist" + app:destination="@id/rename_playlist_dialog" /> + @@ -58,16 +61,13 @@ - + android:name="playlistUid" + app:argType="org.oxycblt.auxio.music.Music$UID" /> + + + + + Playlist Playlists New playlist + Rename + Rename playlist Delete Delete playlist? @@ -166,6 +168,8 @@ Monitoring your music library for changes… Added to queue Playlist created + Playlist renamed + Playlist deleted Added to playlist Developed by Alexander Capehart diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt index 7ae9197b6..600a316d1 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -70,6 +70,10 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } + override fun renamePlaylist(playlist: Playlist, name: String) { + throw NotImplementedError() + } + override fun requestIndex(withCache: Boolean) { throw NotImplementedError() }