detail: add playlist view

Add a detail view for playlists.

This is most equivelent to the genre detail view right now, but will be
differentiated eventually.
This commit is contained in:
Alexander Capehart 2023-03-22 19:58:06 -06:00
parent f3a2d94086
commit 4068c3e009
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
24 changed files with 628 additions and 56 deletions

View file

@ -77,24 +77,26 @@ object IntegerTable {
const val MUSIC_MODE_GENRES = 0xA108
/** MusicMode.PLAYLISTS */
const val MUSIC_MODE_PLAYLISTS = 0xA107
/** Sort.ByName */
/** Sort.Mode.ByName */
const val SORT_BY_NAME = 0xA10C
/** Sort.ByArtist */
/** Sort.Mode.ByArtist */
const val SORT_BY_ARTIST = 0xA10D
/** Sort.ByAlbum */
/** Sort.Mode.ByAlbum */
const val SORT_BY_ALBUM = 0xA10E
/** Sort.ByYear */
/** Sort.Mode.ByYear */
const val SORT_BY_YEAR = 0xA10F
/** Sort.ByDuration */
/** Sort.Mode.ByDuration */
const val SORT_BY_DURATION = 0xA114
/** Sort.ByCount */
/** Sort.Mode.ByCount */
const val SORT_BY_COUNT = 0xA115
/** Sort.ByDisc */
/** Sort.Mode.ByDisc */
const val SORT_BY_DISC = 0xA116
/** Sort.ByTrack */
/** Sort.Mode.ByTrack */
const val SORT_BY_TRACK = 0xA117
/** Sort.ByDateAdded */
/** Sort.Mode.ByDateAdded */
const val SORT_BY_DATE_ADDED = 0xA118
/** Sort.Mode.None */
const val SORT_BY_NONE = 0xA11F
/** ReplayGainMode.Off (No longer used but still reserved) */
// const val REPLAY_GAIN_MODE_OFF = 0xA110
/** ReplayGainMode.Track */

View file

@ -159,8 +159,10 @@ class AlbumDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_album_sort) {
// Select the corresponding sort mode option
val sort = detailModel.albumSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
// Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@ -171,8 +173,10 @@ class AlbumDetailFragment :
item.isChecked = !item.isChecked
detailModel.albumSongSort =
when (item.itemId) {
// Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
// Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true

View file

@ -87,7 +87,7 @@ class ArtistDetailFragment :
// --- UI SETUP ---
binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail)
inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@ArtistDetailFragment)
}
@ -97,7 +97,7 @@ class ArtistDetailFragment :
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtistUid(args.artistUid)
collectImmediately(detailModel.currentArtist, ::updateItem)
collectImmediately(detailModel.currentArtist, ::updateArtist)
collectImmediately(detailModel.artistList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -171,8 +171,10 @@ class ArtistDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_artist_sort) {
// Select the corresponding sort mode option
val sort = detailModel.artistSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
// Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@ -184,8 +186,10 @@ class ArtistDetailFragment :
detailModel.artistSongSort =
when (item.itemId) {
// Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
// Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
@ -194,7 +198,7 @@ class ArtistDetailFragment :
}
}
private fun updateItem(artist: Artist?) {
private fun updateArtist(artist: Artist?) {
if (artist == null) {
// Artist we were showing no longer exists.
findNavController().navigateUp()

View file

@ -143,6 +143,31 @@ constructor(
currentGenre.value?.let { refreshGenreList(it, true) }
}
// --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?>
get() = _currentPlaylist
private val _playlistList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentPlaylist] */
val playlistList: StateFlow<List<Item>> = _playlistList
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [playlistList] in the UI. */
val playlistInstructions: Event<UpdateInstructions>
get() = _playlistInstructions
/** The current [Sort] used for [Song]s in [playlistList]. */
var playlistSongSort: Sort
get() = musicSettings.playlistSongSort
set(value) {
logD(value)
musicSettings.playlistSongSort = value
logD(musicSettings.playlistSongSort)
// Refresh the playlist list to reflect the new sort.
currentPlaylist.value?.let { refreshPlaylistList(it, true) }
}
/**
* The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently
* shown item.
@ -161,6 +186,7 @@ constructor(
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
val deviceLibrary = musicRepository.deviceLibrary ?: return
val userLibrary = musicRepository.userLibrary ?: return
// If we are showing any item right now, we will need to refresh it (and any information
// related to it) with the new library in order to prevent stale items from showing up
@ -175,13 +201,13 @@ constructor(
val album = currentAlbum.value
if (album != null) {
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
logD("Updated genre to ${currentAlbum.value}")
logD("Updated album to ${currentAlbum.value}")
}
val artist = currentArtist.value
if (artist != null) {
_currentArtist.value = deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
logD("Updated genre to ${currentArtist.value}")
logD("Updated artist to ${currentArtist.value}")
}
val genre = currentGenre.value
@ -189,6 +215,12 @@ constructor(
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
logD("Updated genre to ${currentGenre.value}")
}
val playlist = currentPlaylist.value
if (playlist != null) {
_currentPlaylist.value =
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
}
}
/**
@ -254,6 +286,22 @@ constructor(
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
}
/**
* Set a new [currentPlaylist] from it's [Music.UID]. If the [Music.UID] differs,
* [currentPlaylist] and [currentPlaylist] will be updated to align with the new album.
*
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
*/
fun setPlaylistUid(uid: Music.UID) {
if (_currentPlaylist.value?.uid == uid) {
// Nothing to do.
return
}
logD("Opening Playlist [uid: $uid]")
_currentPlaylist.value =
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
}
private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
@ -267,7 +315,7 @@ constructor(
}
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album data")
logD("Refreshing album list")
val list = mutableListOf<Item>()
list.add(SortHeader(R.string.lbl_songs))
val instructions =
@ -299,7 +347,7 @@ constructor(
}
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
logD("Refreshing artist data")
logD("Refreshing artist list")
val list = mutableListOf<Item>()
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
@ -348,7 +396,7 @@ constructor(
}
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
logD("Refreshing genre data")
logD("Refreshing genre list")
val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs.
list.add(BasicHeader(R.string.lbl_artists))
@ -366,6 +414,21 @@ constructor(
_genreList.value = list
}
private fun refreshPlaylistList(playlist: Playlist, replace: Boolean = false) {
logD("Refreshing playlist list")
val list = mutableListOf<Item>()
list.add(SortHeader(R.string.lbl_songs))
val instructions =
if (replace) {
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
}
list.addAll(playlistSongSort.songs(playlist.songs))
_playlistInstructions.put(instructions)
_playlistList.value = list
}
/**
* A simpler mapping of [ReleaseType] used for grouping and sorting songs.
*

View file

@ -81,7 +81,7 @@ class GenreDetailFragment :
// --- UI SETUP ---
binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail)
inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@GenreDetailFragment)
}
@ -91,7 +91,7 @@ class GenreDetailFragment :
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setGenreUid(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updateItem)
collectImmediately(detailModel.currentGenre, ::updatePlaylist)
collectImmediately(detailModel.genreList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -165,8 +165,10 @@ class GenreDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_genre_sort) {
// Select the corresponding sort mode option
val sort = detailModel.genreSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
// Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@ -177,8 +179,10 @@ class GenreDetailFragment :
item.isChecked = !item.isChecked
detailModel.genreSongSort =
when (item.itemId) {
// Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
// Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
@ -186,7 +190,7 @@ class GenreDetailFragment :
}
}
private fun updateItem(genre: Genre?) {
private fun updatePlaylist(genre: Genre?) {
if (genre == null) {
// Genre we were showing no longer exists.
findNavController().navigateUp()

View file

@ -0,0 +1,242 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
* A [ListFragment] that shows information for a particular [Playlist].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class PlaylistDetailFragment :
ListFragment<Song, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> {
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what playlist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an playlist.
private val args: PlaylistDetailFragmentArgs by navArgs()
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply {
inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
}
binding.detailRecycler.adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setPlaylistUid(args.playlistUid)
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
collectImmediately(detailModel.playlistList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.playlistInstructions.consume()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
// TODO: Handle
val currentPlaylist = unlikelyToBeNull(detailModel.currentPlaylist.value)
return when (item.itemId) {
R.id.action_play_next -> {
// playbackModel.playNext(currentPlaylist)
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_queue_add -> {
// playbackModel.addToQueue(currentPlaylist)
requireContext().showToast(R.string.lng_queue_added)
true
}
else -> false
}
}
override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) {
// TODO: Handle
}
override fun onRealClick(item: Song) {
// TODO: Handle
}
override fun onOpenMenu(item: Song, anchor: View) {
openMusicMenu(anchor, R.menu.menu_song_actions, item)
}
override fun onPlay() {
// TODO: Handle
// playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onShuffle() {
// playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_playlist_sort) {
// Select the corresponding sort mode option
val sort = detailModel.playlistSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
// Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
Sort.Direction.DESCENDING -> R.id.option_sort_dec
}
unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true
// If there is no sort specified, disable the ascending/descending options, as
// they make no sense. We still do want to indicate the state however, in the case
// that the user wants to switch to a sort mode where they do make sense.
if (sort.mode is Sort.Mode.ByNone) {
menu.findItem(R.id.option_sort_dec).isEnabled = false
menu.findItem(R.id.option_sort_asc).isEnabled = false
}
setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked
detailModel.playlistSongSort =
when (item.itemId) {
// Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
// Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
}
}
}
private fun updatePlaylist(playlist: Playlist?) {
if (playlist == null) {
// Playlist we were showing no longer exists.
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = playlist.resolveName(requireContext())
playlistHeaderAdapter.setParent(playlist)
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
// Prefer songs that might be playing from this playlist.
if (parent is Playlist &&
parent.uid == unlikelyToBeNull(detailModel.currentPlaylist.value).uid) {
playlistListAdapter.setPlaying(song, isPlaying)
} else {
playlistListAdapter.setPlaying(null, isPlaying)
}
}
private fun handleNavigation(item: Music?) {
when (item) {
is Song -> {
logD("Navigating to another song")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.album.uid))
}
is Album -> {
logD("Navigating to another album")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.uid))
}
is Artist -> {
logD("Navigating to another artist")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.actionShowArtist(item.uid))
}
is Playlist -> {
navModel.exploreNavigationItem.consume()
}
else -> {}
}
}
private fun updateList(list: List<Item>) {
playlistListAdapter.update(list, detailModel.playlistInstructions.consume())
}
private fun updateSelection(selected: List<Music>) {
playlistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Album] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderAdapter(private val listener: Listener) :

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Artist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderAdapter(private val listener: Listener) :

View file

@ -24,7 +24,6 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
@ -33,6 +32,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Genre] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderAdapter(private val listener: Listener) :
@ -57,7 +57,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
* Bind new data to this instance.
*
* @param genre The new [Genre] to bind.
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(genre)
@ -65,7 +65,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
binding.detailName.text = genre.resolveName(binding.context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song count of the genre maps to the info text.
// The song and artist count of the genre maps to the info text.
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,

View file

@ -0,0 +1,85 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Playlist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Playlist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param playlist The new [Playlist] to bind.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(playlist: Playlist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(playlist)
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.resolveName(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song count of the playlist maps to the info text.
binding.detailInfo.text =
binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size)
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
PlaylistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -30,7 +30,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
/**
* An [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailListAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.list
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
/**
* A [DetailListAdapter] implementing the header and sub-items for the [Playlist] detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailListAdapter(private val listener: Listener<Song>) :
DetailListAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support generic song items.
is Song -> SongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
if (viewType == SongViewHolder.VIEW_TYPE) {
SongViewHolder.from(parent)
} else {
super.onCreateViewHolder(parent, viewType)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
val item = getItem(position)
if (item is Song) {
(holder as SongViewHolder).bind(item, 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<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Song && newItem is Song ->
SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}

View file

@ -438,7 +438,8 @@ class HomeFragment :
is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
else -> return
is Playlist -> HomeFragmentDirections.actionShowPlaylist(item.uid)
null -> return
}
setupAxisTransitions(MaterialSharedAxis.X)

View file

@ -22,7 +22,6 @@ import android.view.MenuItem
import android.view.View
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.internal.view.SupportMenu
import androidx.core.view.MenuCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
@ -281,7 +280,6 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
currentMenu =
PopupMenu(requireContext(), anchor).apply {
inflate(menuRes)
logD(menu is SupportMenu)
MenuCompat.setGroupDividerEnabled(menu, true)
block()
setOnDismissListener { currentMenu = null }

View file

@ -114,23 +114,28 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
private fun songsInPlace(songs: MutableList<out Song>) {
songs.sortWith(mode.getSongComparator(direction))
val comparator = mode.getSongComparator(direction) ?: return
songs.sortWith(comparator)
}
private fun albumsInPlace(albums: MutableList<out Album>) {
albums.sortWith(mode.getAlbumComparator(direction))
val comparator = mode.getAlbumComparator(direction) ?: return
albums.sortWith(comparator)
}
private fun artistsInPlace(artists: MutableList<out Artist>) {
artists.sortWith(mode.getArtistComparator(direction))
val comparator = mode.getArtistComparator(direction) ?: return
artists.sortWith(comparator)
}
private fun genresInPlace(genres: MutableList<out Genre>) {
genres.sortWith(mode.getGenreComparator(direction))
val comparator = mode.getGenreComparator(direction) ?: return
genres.sortWith(comparator)
}
private fun playlistsInPlace(playlists: MutableList<out Playlist>) {
playlists.sortWith(mode.getPlaylistComparator(direction))
val comparator = mode.getPlaylistComparator(direction) ?: return
playlists.sortWith(comparator)
}
/**
@ -160,50 +165,57 @@ data class Sort(val mode: Mode, val direction: Direction) {
* Get a [Comparator] that sorts [Song]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode].
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode],
* or null to not sort at all.
*/
open fun getSongComparator(direction: Direction): Comparator<Song> {
throw UnsupportedOperationException()
}
open fun getSongComparator(direction: Direction): Comparator<Song>? = null
/**
* Get a [Comparator] that sorts [Album]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode].
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode],
* or null to not sort at all.
*/
open fun getAlbumComparator(direction: Direction): Comparator<Album> {
throw UnsupportedOperationException()
}
open fun getAlbumComparator(direction: Direction): Comparator<Album>? = null
/**
* Return a [Comparator] that sorts [Artist]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode].
* or null to not sort at all.
*/
open fun getArtistComparator(direction: Direction): Comparator<Artist> {
throw UnsupportedOperationException()
}
open fun getArtistComparator(direction: Direction): Comparator<Artist>? = null
/**
* Return a [Comparator] that sorts [Genre]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
* or null to not sort at all.
*/
open fun getGenreComparator(direction: Direction): Comparator<Genre> {
throw UnsupportedOperationException()
}
open fun getGenreComparator(direction: Direction): Comparator<Genre>? = null
/**
* Return a [Comparator] that sorts [Playlist]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
* or null to not sort at all.
*/
open fun getPlaylistComparator(direction: Direction): Comparator<Playlist> {
throw UnsupportedOperationException()
open fun getPlaylistComparator(direction: Direction): Comparator<Playlist>? = null
/**
* Sort by the item's natural order.
*
* @see Music.sortName
*/
object ByNone : Mode() {
override val intCode: Int
get() = IntegerTable.SORT_BY_NONE
override val itemId: Int
get() = R.id.option_sort_none
}
/**
@ -614,6 +626,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
*/
fun fromIntCode(intCode: Int) =
when (intCode) {
ByNone.intCode -> ByNone
ByName.intCode -> ByName
ByArtist.intCode -> ByArtist
ByAlbum.intCode -> ByAlbum
@ -635,6 +648,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
*/
fun fromItemId(@IdRes itemId: Int) =
when (itemId) {
ByNone.itemId -> ByNone
ByName.itemId -> ByName
ByAlbum.itemId -> ByAlbum
ByArtist.itemId -> ByArtist

View file

@ -46,6 +46,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
var multiValueSeparators: String
/** Whether to trim english articles with song sort names. */
val automaticSortNames: Boolean
// TODO: Move sort settings to list module
/** The [Sort] mode used in [Song] lists. */
var songSort: Sort
/** The [Sort] mode used in [Album] lists. */
@ -62,8 +63,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
var artistSongSort: Sort
/** The [Sort] mode used in a [Genre]'s [Song] list. */
var genreSongSort: Sort
/** The [Sort] mode used in a [Playlist]'s [Song] list, or null if sorting by original ordering. */
var playlistSongSort: Sort?
/** The [Sort] mode used in a [Playlist]'s [Song] list. */
var playlistSongSort: Sort
interface Listener {
/** Called when a setting controlling how music is loaded has changed. */
@ -224,12 +225,15 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
}
}
override var playlistSongSort: Sort?
get() = Sort.fromIntCode(sharedPreferences.getInt(
override var playlistSongSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_playlist_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByNone, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.lbl_playlist), value?.intCode ?: -1)
putInt(getString(R.string.set_key_playlist_songs_sort), value.intCode)
apply()
}
}

View file

@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.user
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.device.DeviceLibrary
@ -75,5 +76,17 @@ private class UserLibraryImpl(
override val playlists: List<Playlist>
get() = playlistMap.values.toList()
init {
val uid = Music.UID.auxio(MusicMode.PLAYLISTS) { update("Playlist 1".toByteArray()) }
playlistMap[uid] =
PlaylistImpl(
RawPlaylist(
PlaylistInfo(uid, "Playlist 1"),
deviceLibrary.songs.slice(10..30).map { PlaylistSong(it.uid) }),
deviceLibrary,
musicSettings,
)
}
override fun findPlaylist(uid: Music.UID) = playlistMap[uid]
}

View file

@ -20,7 +20,6 @@ package org.oxycblt.auxio.playback
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.room.util.query
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job

View file

@ -178,7 +178,8 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
is Album -> SearchFragmentDirections.actionShowAlbum(item.uid)
is Artist -> SearchFragmentDirections.actionShowArtist(item.uid)
is Genre -> SearchFragmentDirections.actionShowGenre(item.uid)
else -> return
is Playlist -> SearchFragmentDirections.actionShowPlaylist(item.uid)
null -> return
}
// Keyboard is no longer needed.
hideKeyboard()

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:checkableBehavior="single"
android:id="@+id/sort_modes">
<item
android:id="@+id/option_sort_none"
android:title="@string/lbl_none" />
<item
android:id="@+id/option_sort_name"
android:title="@string/lbl_name" />
<item
android:id="@+id/option_sort_artist"
android:title="@string/lbl_artist" />
<item
android:id="@+id/option_sort_album"
android:title="@string/lbl_album" />
<item
android:id="@+id/option_sort_year"
android:title="@string/lbl_date" />
<item
android:id="@+id/option_sort_duration"
android:title="@string/lbl_duration" />
</group>
<group android:checkableBehavior="single"
android:id="@+id/sort_direction">
<item
android:id="@+id/option_sort_asc"
android:title="@string/lbl_sort_asc" />
<item
android:id="@+id/option_sort_dec"
android:title="@string/lbl_sort_dec" />
</group>
</menu>

View file

@ -49,11 +49,29 @@
android:id="@+id/action_show_album"
app:destination="@id/album_detail_fragment" />
</fragment>
<fragment
android:id="@+id/playlist_detail_fragment"
android:name="org.oxycblt.auxio.detail.PlaylistDetailFragment"
android:label="PlaylistDetailFragment"
tools:layout="@layout/fragment_detail">
<argument
android:name="playlistUid"
app:argType="org.oxycblt.auxio.music.Music$UID" />
<action
android:id="@+id/action_show_artist"
app:destination="@id/artist_detail_fragment" />
<action
android:id="@+id/action_show_album"
app:destination="@id/album_detail_fragment" />
</fragment>
<fragment
android:id="@+id/search_fragment"
android:name="org.oxycblt.auxio.search.SearchFragment"
android:label="SearchFragment"
tools:layout="@layout/fragment_search">
<action
android:id="@+id/action_show_playlist"
app:destination="@id/playlist_detail_fragment" />
<action
android:id="@+id/action_show_genre"
app:destination="@id/genre_detail_fragment" />
@ -72,6 +90,9 @@
<action
android:id="@+id/action_show_search"
app:destination="@id/search_fragment" />
<action
android:id="@+id/action_show_playlist"
app:destination="@id/playlist_detail_fragment" />
<action
android:id="@+id/action_show_genre"
app:destination="@id/genre_detail_fragment" />

View file

@ -86,6 +86,7 @@
<!-- As in to not filter -->
<string name="lbl_filter_all">All</string>
<string name="lbl_none">None</string>
<string name="lbl_name">Name</string>
<string name="lbl_date">Date</string>
<string name="lbl_duration">Duration</string>

View file

@ -60,4 +60,7 @@ open class FakeMusicSettings : MusicSettings {
override var genreSongSort: Sort
get() = throw NotImplementedError()
set(_) = throw NotImplementedError()
override var playlistSongSort: Sort?
get() = throw NotImplementedError()
set(_) = throw NotImplementedError()
}