Merge branch 'dev' into feature/share

This commit is contained in:
Alexander Capehart 2023-05-18 23:20:53 +00:00 committed by GitHub
commit 41cab82523
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 484 additions and 229 deletions

View file

@ -12,6 +12,9 @@ be parsed as images
- Fixed issue where searches would match song file names case-sensitively - 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 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 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 #### Dev/Meta
- Switched to androidx media3 (New Home of ExoPlayer) for backing player components - Switched to androidx media3 (New Home of ExoPlayer) for backing player components

View file

@ -37,16 +37,18 @@ object IntegerTable {
const val VIEW_TYPE_PLAYLIST = 0xA004 const val VIEW_TYPE_PLAYLIST = 0xA004
/** BasicHeaderViewHolder */ /** BasicHeaderViewHolder */
const val VIEW_TYPE_BASIC_HEADER = 0xA005 const val VIEW_TYPE_BASIC_HEADER = 0xA005
/** DividerViewHolder */
const val VIEW_TYPE_DIVIDER = 0xA006
/** SortHeaderViewHolder */ /** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA006 const val VIEW_TYPE_SORT_HEADER = 0xA007
/** AlbumSongViewHolder */ /** AlbumSongViewHolder */
const val VIEW_TYPE_ALBUM_SONG = 0xA007 const val VIEW_TYPE_ALBUM_SONG = 0xA008
/** ArtistAlbumViewHolder */ /** ArtistAlbumViewHolder */
const val VIEW_TYPE_ARTIST_ALBUM = 0xA008 const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
/** ArtistSongViewHolder */ /** ArtistSongViewHolder */
const val VIEW_TYPE_ARTIST_SONG = 0xA009 const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */ /** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00A const val VIEW_TYPE_DISC_HEADER = 0xA00B
/** "Music playback" notification code */ /** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */ /** "Music loading" notification code */

View file

@ -136,8 +136,9 @@ class MainFragment :
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist) collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@ -315,12 +316,11 @@ class MainFragment :
} }
} }
private fun handleAddToPlaylist(songs: List<Song>?) { private fun handleRenamePlaylist(playlist: Playlist?) {
if (songs != null) { if (playlist != null) {
findNavController() findNavController()
.navigateSafe( .navigateSafe(MainFragmentDirections.actionRenamePlaylist(playlist.uid))
MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray())) musicModel.playlistToRename.consume()
musicModel.songsToAdd.consume()
} }
} }
@ -331,6 +331,16 @@ class MainFragment :
musicModel.playlistToDelete.consume() musicModel.playlistToDelete.consume()
} }
} }
private fun handleAddToPlaylist(songs: List<Song>?) {
if (songs != null) {
findNavController()
.navigateSafe(
MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray()))
musicModel.songsToAdd.consume()
}
}
private fun handlePlaybackArtistPicker(song: Song?) { private fun handlePlaybackArtistPicker(song: Song?) {
if (song != null) { if (song != null) {
navModel.mainNavigateTo( navModel.mainNavigateTo(

View file

@ -26,6 +26,7 @@ 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.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint 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.header.AlbumDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter 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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort 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.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
@ -95,7 +99,17 @@ class AlbumDetailFragment :
setOnMenuItemClickListener(this@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 --- // -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.

View file

@ -26,6 +26,7 @@ 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.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
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
@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter 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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
@ -94,7 +97,17 @@ class ArtistDetailFragment :
setOnMenuItemClickListener(this@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 --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.

View file

@ -32,6 +32,7 @@ import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
@ -297,7 +298,9 @@ constructor(
private fun refreshAlbumList(album: Album, replace: Boolean = false) { private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album list") logD("Refreshing album list")
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
list.add(SortHeader(R.string.lbl_songs)) val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
val instructions = val instructions =
if (replace) { if (replace) {
// Intentional so that the header item isn't replaced with the songs // 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}") logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) { 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) list.addAll(entry.value)
} }
@ -363,7 +368,9 @@ constructor(
var instructions: UpdateInstructions = UpdateInstructions.Diff var instructions: UpdateInstructions = UpdateInstructions.Diff
if (artist.songs.isNotEmpty()) { if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header") 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) { if (replace) {
// Intentional so that the header item isn't replaced with the songs // Intentional so that the header item isn't replaced with the songs
instructions = UpdateInstructions.Replace(list.size) instructions = UpdateInstructions.Replace(list.size)
@ -379,9 +386,14 @@ constructor(
logD("Refreshing genre list") logD("Refreshing genre list")
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs. // 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.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 = val instructions =
if (replace) { if (replace) {
// Intentional so that the header item isn't replaced with the songs // Intentional so that the header item isn't replaced with the songs
@ -400,7 +412,9 @@ constructor(
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
if (playlist.songs.isNotEmpty()) { 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) { if (replace) {
instructions = UpdateInstructions.Replace(list.size) instructions = UpdateInstructions.Replace(list.size)
} }

View file

@ -26,6 +26,7 @@ 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.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
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
@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter 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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
@ -87,7 +90,17 @@ class GenreDetailFragment :
setOnMenuItemClickListener(this@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 --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.

View file

@ -26,6 +26,7 @@ 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.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
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
@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter 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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
@ -87,7 +90,17 @@ class PlaylistDetailFragment :
setOnMenuItemClickListener(this@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 --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
@ -126,6 +139,10 @@ class PlaylistDetailFragment :
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_rename -> {
musicModel.renamePlaylist(currentPlaylist)
true
}
R.id.action_delete -> { R.id.action_delete -> {
musicModel.deletePlaylist(currentPlaylist) musicModel.deletePlaylist(currentPlaylist)
true true

View file

@ -69,15 +69,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
} }
} }
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 { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =

View file

@ -65,14 +65,6 @@ class ArtistDetailListAdapter(private val listener: Listener<Music>) :
} }
} }
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 { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =

View file

@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
@ -47,13 +48,12 @@ import org.oxycblt.auxio.util.inflater
abstract class DetailListAdapter( abstract class DetailListAdapter(
private val listener: Listener<*>, private val listener: Listener<*>,
private val diffCallback: DiffUtil.ItemCallback<Item> private val diffCallback: DiffUtil.ItemCallback<Item>
) : ) : SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback) {
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
// Implement support for headers and sort headers // Implement support for headers and sort headers
is Divider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
@ -61,6 +61,7 @@ abstract class DetailListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent) SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType") 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. */ /** An extended [SelectableListListener] for [DetailListAdapter] implementations. */
interface Listener<in T : Music> : SelectableListListener<T> { interface Listener<in T : Music> : SelectableListListener<T> {
/** /**
@ -94,6 +89,8 @@ abstract class DetailListAdapter(
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { return when {
oldItem is Divider && newItem is Divider ->
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is BasicHeader && newItem is BasicHeader -> oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader -> oldItem is SortHeader && newItem is SortHeader ->

View file

@ -60,14 +60,6 @@ class GenreDetailListAdapter(private val listener: Listener<Music>) :
} }
} }
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 { private companion object {
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {

View file

@ -56,14 +56,6 @@ class PlaylistDetailListAdapter(private val listener: Listener<Song>) :
} }
} }
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 { companion object {
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {

View file

@ -36,12 +36,11 @@ import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
class SongKeyer @Inject constructor() : Keyer<Song> { class SongKeyer @Inject constructor() : Keyer<Song> {
override fun key(data: Song, options: Options) = override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}"
"${data.album.uid}${data.album.songs.hashCode()}"
} }
class ParentKeyer @Inject constructor() : Keyer<MusicParent> { class ParentKeyer @Inject constructor() : Keyer<MusicParent> {
override fun key(data: MusicParent, options: Options) = "${data.uid}${data.songs.hashCode()}" override fun key(data: MusicParent, options: Options) = "${data.uid}${data.hashCode()}"
} }
/** /**

View file

@ -40,3 +40,11 @@ interface Header : Item {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class BasicHeader(@StringRes override val titleRes: Int) : Header 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

View file

@ -268,6 +268,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
playbackModel.addToQueue(playlist) playbackModel.addToQueue(playlist)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
} }
R.id.action_rename -> {
musicModel.renamePlaylist(playlist)
}
R.id.action_delete -> { R.id.action_delete -> {
musicModel.deletePlaylist(playlist) musicModel.deletePlaylist(playlist)
} }

View file

@ -23,18 +23,14 @@ import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* A [RecyclerView] with a few QoL extensions, such as: * A [RecyclerView] with a few QoL extensions, such as:
* - Automatic edge-to-edge support * - Automatic edge-to-edge support
* - Adapter-based [SpanSizeLookup] implementation
* - Automatic [setHasFixedSize] setup * - Automatic [setHasFixedSize] setup
* *
* FIXME: Broken span configuration
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
open class AuxioRecyclerView 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, // Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
// so we can enable fixed-size optimizations. // so we can enable fixed-size optimizations.
setHasFixedSize(true) setHasFixedSize(true)
addItemDecoration(HeaderItemDecoration(context))
} }
final override fun setHasFixedSize(hasFixedSize: Boolean) { final override fun setHasFixedSize(hasFixedSize: Boolean) {
@ -67,36 +62,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom) updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
return insets 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
}
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -20,12 +20,14 @@ package org.oxycblt.auxio.list.recycler
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback 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. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Genre>() { object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.name == newItem.name && oldItem.name == newItem.name &&
oldItem.artists.size == newItem.artists.size && oldItem.artists.size == newItem.artists.size &&
oldItem.songs.size == newItem.songs.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. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Playlist>() { object : SimpleDiffCallback<Playlist>() {
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 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. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<BasicHeader>() { object : SimpleDiffCallback<BasicHeader>() {
override fun areContentsTheSame( override fun areContentsTheSame(oldItem: BasicHeader, newItem: BasicHeader) =
oldItem: BasicHeader, oldItem.titleRes == newItem.titleRes
newItem: BasicHeader }
): Boolean = 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<Divider>() {
override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
oldItem.anchor == newItem.anchor
} }
} }
} }

View file

@ -118,6 +118,16 @@ sealed interface Music : Item {
} }
companion object { 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, * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
* unlikely-to-change metadata of the music. * unlikely-to-change metadata of the music.

View file

@ -118,6 +118,14 @@ interface MusicRepository {
*/ */
fun createPlaylist(name: String, songs: List<Song>) fun createPlaylist(name: String, songs: List<Song>)
/**
* 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]. * 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) { override fun deletePlaylist(playlist: Playlist) {
val userLibrary = userLibrary ?: return val userLibrary = userLibrary ?: return
userLibrary.deletePlaylist(playlist) userLibrary.deletePlaylist(playlist)

View file

@ -52,15 +52,20 @@ constructor(
/** Flag for opening a dialog to create a playlist of the given [Song]s. */ /** Flag for opening a dialog to create a playlist of the given [Song]s. */
val newPlaylistSongs: Event<List<Song>> = _newPlaylistSongs val newPlaylistSongs: Event<List<Song>> = _newPlaylistSongs
private val _songsToAdd = MutableEvent<List<Song>>() private val _playlistToRename = MutableEvent<Playlist?>()
/** Flag for opening a dialog to add the given [Song]s to a playlist. */ /** Flag for opening a dialog to rename the given [Playlist]. */
val songsToAdd: Event<List<Song>> = _songsToAdd val playlistToRename: Event<Playlist?>
get() = _playlistToRename
private val _playlistToDelete = MutableEvent<Playlist>() private val _playlistToDelete = MutableEvent<Playlist>()
/** Flag for opening a dialog to confirm deletion of the given [Playlist]. */ /** Flag for opening a dialog to confirm deletion of the given [Playlist]. */
val playlistToDelete: Event<Playlist> val playlistToDelete: Event<Playlist>
get() = _playlistToDelete get() = _playlistToDelete
private val _songsToAdd = MutableEvent<List<Song>>()
/** Flag for opening a dialog to add the given [Song]s to a playlist. */
val songsToAdd: Event<List<Song>> = _songsToAdd
init { init {
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(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]. * Delete a [Playlist].
* *

View file

@ -73,7 +73,7 @@ class AddToPlaylistDialog :
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
pickerModel.setSongsToAdd(args.songUids) pickerModel.setSongsToAdd(args.songUids)
collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs)
collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices) collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices)
} }
override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { override fun onDestroyBinding(binding: DialogMusicChoicesBinding) {

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -54,6 +55,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment<DialogDeletePlaylistBindi
// Now we can delete the playlist for-real this time. // Now we can delete the playlist for-real this time.
musicModel.deletePlaylist( musicModel.deletePlaylist(
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true) unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
requireContext().showToast(R.string.lng_playlist_deleted)
} }
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
} }

View file

@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* A dialog allowing the name of a new/existing playlist to be edited. * A dialog allowing the name of a new playlist to be chosen before committing it to the database.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */

View file

@ -41,24 +41,34 @@ import org.oxycblt.auxio.music.Song
class PlaylistPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : class PlaylistPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
ViewModel(), MusicRepository.UpdateListener { ViewModel(), MusicRepository.UpdateListener {
private val _currentPendingPlaylist = MutableStateFlow<PendingPlaylist?>(null) private val _currentPendingPlaylist = MutableStateFlow<PendingPlaylist?>(null)
/** A new [Playlist] having it's name chosen by the user. Null if none yet. */
val currentPendingPlaylist: StateFlow<PendingPlaylist?> val currentPendingPlaylist: StateFlow<PendingPlaylist?>
get() = _currentPendingPlaylist get() = _currentPendingPlaylist
private val _currentPlaylistToRename = MutableStateFlow<Playlist?>(null)
/** An existing [Playlist] that is being renamed. Null if none yet. */
val currentPlaylistToRename: StateFlow<Playlist?>
get() = _currentPlaylistToRename
private val _currentPlaylistToDelete = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */
val currentPlaylistToDelete: StateFlow<Playlist?>
get() = _currentPlaylistToDelete
private val _chosenName = MutableStateFlow<ChosenName>(ChosenName.Empty) private val _chosenName = MutableStateFlow<ChosenName>(ChosenName.Empty)
/** The users chosen name for [currentPendingPlaylist] or [currentPlaylistToRename]. */
val chosenName: StateFlow<ChosenName> val chosenName: StateFlow<ChosenName>
get() = _chosenName get() = _chosenName
private val _currentSongsToAdd = MutableStateFlow<List<Song>?>(null) private val _currentSongsToAdd = MutableStateFlow<List<Song>?>(null)
/** A batch of [Song]s to add to a playlist chosen by the user. Null if none yet. */
val currentSongsToAdd: StateFlow<List<Song>?> val currentSongsToAdd: StateFlow<List<Song>?>
get() = _currentSongsToAdd get() = _currentSongsToAdd
private val _playlistChoices = MutableStateFlow<List<PlaylistChoice>>(listOf()) private val _playlistAddChoices = MutableStateFlow<List<PlaylistChoice>>(listOf())
val playlistChoices: StateFlow<List<PlaylistChoice>> /** The [Playlist]s that [currentSongsToAdd] could be added to. */
get() = _playlistChoices val playlistAddChoices: StateFlow<List<PlaylistChoice>>
get() = _playlistAddChoices
private val _currentPlaylistToDelete = MutableStateFlow<Playlist?>(null)
val currentPlaylistToDelete: StateFlow<Playlist?>
get() = _currentPlaylistToDelete
init { init {
musicRepository.addUpdateListener(this) 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. * 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<Song>) { private fun refreshPlaylistChoices(songs: List<Song>) {
val userLibrary = musicRepository.userLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return
_playlistChoices.value = _playlistAddChoices.value =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
val songSet = it.songs.toSet() val songSet = it.songs.toSet()
PlaylistChoice(it, songs.all(songSet::contains)) 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)
}
} }
/** /**

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<DialogPlaylistNameBinding>() {
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
}
}

View file

@ -21,7 +21,6 @@ package org.oxycblt.auxio.music.user
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.util.update
class PlaylistImpl class PlaylistImpl
private constructor( private constructor(
@ -33,6 +32,15 @@ private constructor(
override val albums = override val albums =
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } 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. * 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<Song>.() -> Unit) = edit(songs.toMutableList().apply(edits)) inline fun edit(edits: MutableList<Song>.() -> Unit) = edit(songs.toMutableList().apply(edits))
override fun equals(other: Any?) = 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 { companion object {
/** /**
@ -62,7 +75,7 @@ private constructor(
*/ */
fun from(name: String, songs: List<Song>, musicSettings: MusicSettings) = fun from(name: String, songs: List<Song>, musicSettings: MusicSettings) =
PlaylistImpl( PlaylistImpl(
Music.UID.auxio(MusicMode.PLAYLISTS) { update(name) }, Music.UID.auxio(MusicMode.PLAYLISTS),
Name.Known.from(name, null, musicSettings), Name.Known.from(name, null, musicSettings),
songs) songs)

View file

@ -80,6 +80,14 @@ interface MutableUserLibrary : UserLibrary {
*/ */
fun createPlaylist(name: String, songs: List<Song>) fun createPlaylist(name: String, songs: List<Song>)
/**
* 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]. * Delete a [Playlist].
* *
@ -122,6 +130,13 @@ private class UserLibraryImpl(
playlistMap[playlistImpl.uid] = playlistImpl 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 @Synchronized
override fun deletePlaylist(playlist: Playlist) { override fun deletePlaylist(playlist: Playlist) {
playlistMap.remove(playlist.uid) playlistMap.remove(playlist.uid)

View file

@ -34,8 +34,7 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SearchAdapter(private val listener: SelectableListListener<Music>) : class SearchAdapter(private val listener: SelectableListListener<Music>) :
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(DIFF_CALLBACK), SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
@ -44,6 +43,7 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
is Artist -> ArtistViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE
is Genre -> GenreViewHolder.VIEW_TYPE is Genre -> GenreViewHolder.VIEW_TYPE
is Playlist -> PlaylistViewHolder.VIEW_TYPE is Playlist -> PlaylistViewHolder.VIEW_TYPE
is Divider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
@ -55,6 +55,7 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent) ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent) GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent)
PlaylistViewHolder.VIEW_TYPE -> PlaylistViewHolder.from(parent) PlaylistViewHolder.VIEW_TYPE -> PlaylistViewHolder.from(parent)
DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType") else -> error("Invalid item type $viewType")
} }
@ -71,8 +72,6 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
} }
} }
override fun isItemFullWidth(position: Int) = getItem(position) is BasicHeader
private companion object { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
@ -87,6 +86,10 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Genre && newItem is Genre -> oldItem is Genre && newItem is Genre ->
GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) 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 -> oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> false else -> false

View file

@ -29,10 +29,13 @@ import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
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.FragmentSearchBinding 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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -104,7 +107,13 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
} }
} }
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 --- // --- VIEWMODEL SETUP ---

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
@ -138,23 +139,44 @@ constructor(
return buildList { return buildList {
results.artists?.let { results.artists?.let {
add(BasicHeader(R.string.lbl_artists)) val header = BasicHeader(R.string.lbl_artists)
add(header)
addAll(SORT.artists(it)) addAll(SORT.artists(it))
} }
results.albums?.let { 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)) addAll(SORT.albums(it))
} }
results.playlists?.let { 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)) addAll(SORT.playlists(it))
} }
results.genres?.let { 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)) addAll(SORT.genres(it))
} }
results.songs?.let { 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)) addAll(SORT.songs(it))
} }
} }

View file

@ -32,6 +32,7 @@ import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException
@ -111,6 +112,20 @@ val ViewBinding.context: Context
*/ */
fun RecyclerView.canScroll() = computeVerticalScrollRange() > height 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 * Fix the double ripple that appears in MaterialButton instances due to an issue with AppCompat 1.5
* or higher. * or higher.

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.divider.MaterialDivider xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

View file

@ -13,9 +13,12 @@
android:id="@+id/action_queue_add" android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" /> android:title="@string/lbl_queue_add" />
<item <item
android:id="@+id/action_share" android:id="@+id/action_rename"
android:title="@string/lbl_share" /> android:title="@string/lbl_rename" />
<item <item
android:id="@+id/action_delete" android:id="@+id/action_delete"
android:title="@string/lbl_delete" /> android:title="@string/lbl_delete" />
<item
android:id="@+id/action_share"
android:title="@string/lbl_share" />
</menu> </menu>

View file

@ -7,9 +7,12 @@
android:id="@+id/action_queue_add" android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" /> android:title="@string/lbl_queue_add" />
<item <item
android:id="@+id/action_share" android:id="@+id/action_rename"
android:title="@string/lbl_share" /> android:title="@string/lbl_rename" />
<item <item
android:id="@+id/action_delete" android:id="@+id/action_delete"
android:title="@string/lbl_delete" /> android:title="@string/lbl_delete" />
<item
android:id="@+id/action_share"
android:title="@string/lbl_share" />
</menu> </menu>

View file

@ -21,11 +21,14 @@
android:id="@+id/action_new_playlist" android:id="@+id/action_new_playlist"
app:destination="@id/new_playlist_dialog" /> app:destination="@id/new_playlist_dialog" />
<action <action
android:id="@+id/action_add_to_playlist" android:id="@+id/action_rename_playlist"
app:destination="@id/add_to_playlist_dialog" /> app:destination="@id/rename_playlist_dialog" />
<action <action
android:id="@+id/action_delete_playlist" android:id="@+id/action_delete_playlist"
app:destination="@id/delete_playlist_dialog" /> app:destination="@id/delete_playlist_dialog" />
<action
android:id="@+id/action_add_to_playlist"
app:destination="@id/add_to_playlist_dialog" />
<action <action
android:id="@+id/action_pick_navigation_artist" android:id="@+id/action_pick_navigation_artist"
app:destination="@id/navigate_to_artist_dialog" /> app:destination="@id/navigate_to_artist_dialog" />
@ -58,16 +61,13 @@
</dialog> </dialog>
<dialog <dialog
android:id="@+id/add_to_playlist_dialog" android:id="@+id/rename_playlist_dialog"
android:name="org.oxycblt.auxio.music.picker.AddToPlaylistDialog" android:name="org.oxycblt.auxio.music.picker.RenamePlaylistDialog"
android:label="new_playlist_dialog" android:label="rename_playlist_dialog"
tools:layout="@layout/dialog_playlist_name"> tools:layout="@layout/dialog_playlist_name">
<argument <argument
android:name="songUids" android:name="playlistUid"
app:argType="org.oxycblt.auxio.music.Music$UID[]" /> app:argType="org.oxycblt.auxio.music.Music$UID" />
<action
android:id="@+id/action_new_playlist"
app:destination="@id/new_playlist_dialog" />
</dialog> </dialog>
<dialog <dialog
@ -80,6 +80,19 @@
app:argType="org.oxycblt.auxio.music.Music$UID" /> app:argType="org.oxycblt.auxio.music.Music$UID" />
</dialog> </dialog>
<dialog
android:id="@+id/add_to_playlist_dialog"
android:name="org.oxycblt.auxio.music.picker.AddToPlaylistDialog"
android:label="new_playlist_dialog"
tools:layout="@layout/dialog_playlist_name">
<argument
android:name="songUids"
app:argType="org.oxycblt.auxio.music.Music$UID[]" />
<action
android:id="@+id/action_new_playlist"
app:destination="@id/new_playlist_dialog" />
</dialog>
<dialog <dialog
android:id="@+id/navigate_to_artist_dialog" android:id="@+id/navigate_to_artist_dialog"
android:name="org.oxycblt.auxio.navigation.picker.NavigateToArtistDialog" android:name="org.oxycblt.auxio.navigation.picker.NavigateToArtistDialog"

View file

@ -79,6 +79,8 @@
<string name="lbl_playlist">Playlist</string> <string name="lbl_playlist">Playlist</string>
<string name="lbl_playlists">Playlists</string> <string name="lbl_playlists">Playlists</string>
<string name="lbl_new_playlist">New playlist</string> <string name="lbl_new_playlist">New playlist</string>
<string name="lbl_rename">Rename</string>
<string name="lbl_rename_playlist">Rename playlist</string>
<string name="lbl_delete">Delete</string> <string name="lbl_delete">Delete</string>
<string name="lbl_confirm_delete_playlist">Delete playlist?</string> <string name="lbl_confirm_delete_playlist">Delete playlist?</string>
@ -166,6 +168,8 @@
<string name="lng_observing">Monitoring your music library for changes…</string> <string name="lng_observing">Monitoring your music library for changes…</string>
<string name="lng_queue_added">Added to queue</string> <string name="lng_queue_added">Added to queue</string>
<string name="lng_playlist_created">Playlist created</string> <string name="lng_playlist_created">Playlist created</string>
<string name="lng_playlist_renamed">Playlist renamed</string>
<string name="lng_playlist_deleted">Playlist deleted</string>
<string name="lng_playlist_added">Added to playlist</string> <string name="lng_playlist_added">Added to playlist</string>
<string name="lng_author">Developed by Alexander Capehart</string> <string name="lng_author">Developed by Alexander Capehart</string>
<!-- As in music library --> <!-- As in music library -->

View file

@ -70,6 +70,10 @@ open class FakeMusicRepository : MusicRepository {
throw NotImplementedError() throw NotImplementedError()
} }
override fun renamePlaylist(playlist: Playlist, name: String) {
throw NotImplementedError()
}
override fun requestIndex(withCache: Boolean) { override fun requestIndex(withCache: Boolean) {
throw NotImplementedError() throw NotImplementedError()
} }