From 743516592955d717b2d006ca71a5458af3de95b1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 13 May 2023 18:54:55 -0600 Subject: [PATCH] music: add playlist addition Implement playlist addition and it's UI flow. --- .../java/org/oxycblt/auxio/MainActivity.kt | 1 + .../java/org/oxycblt/auxio/MainFragment.kt | 14 ++- .../auxio/detail/AlbumDetailFragment.kt | 6 + .../auxio/detail/ArtistDetailFragment.kt | 6 + .../auxio/detail/GenreDetailFragment.kt | 5 + .../auxio/detail/PlaylistDetailFragment.kt | 3 +- .../detail/header/DetailHeaderAdapter.kt | 2 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 2 +- .../auxio/home/list/AlbumListFragment.kt | 1 + .../auxio/home/list/ArtistListFragment.kt | 2 + .../auxio/home/list/GenreListFragment.kt | 2 + .../auxio/home/list/PlaylistListFragment.kt | 4 +- .../auxio/home/list/SongListFragment.kt | 2 + .../auxio/image/extractor/Components.kt | 1 + .../org/oxycblt/auxio/list/ListFragment.kt | 12 ++ .../auxio/list/recycler/AuxioRecyclerView.kt | 2 + .../auxio/list/recycler/ViewHolders.kt | 4 + .../auxio/list/selection/SelectionFragment.kt | 16 ++- .../list/selection/SelectionViewModel.kt | 32 +++++- .../oxycblt/auxio/music/MusicRepository.kt | 19 +++- .../org/oxycblt/auxio/music/MusicViewModel.kt | 78 +++++++++++-- .../music/{config => fs}/DirectoryAdapter.kt | 3 +- .../music/{config => fs}/MusicDirsDialog.kt | 4 +- .../{config => metadata}/SeparatorsDialog.kt | 2 +- .../auxio/music/picker/AddToPlaylistDialog.kt | 104 ++++++++++++++++++ .../auxio/music/picker/NewPlaylistDialog.kt | 13 ++- .../music/picker/NewPlaylistFooterAdapter.kt | 83 ++++++++++++++ .../music/picker/PlaylistChoiceAdapter.kt | 83 ++++++++++++++ .../music/picker/PlaylistPickerViewModel.kt | 73 +++++++++++- .../oxycblt/auxio/music/user/UserLibrary.kt | 2 +- .../picker/NavigateToArtistDialog.kt | 2 +- .../picker/NavigationPickerViewModel.kt | 12 +- .../auxio/playback/PlaybackViewModel.kt | 49 +++------ .../oxycblt/auxio/search/SearchFragment.kt | 3 +- .../main/res/layout/dialog_music_picker.xml | 1 + .../res/layout/item_new_playlist_choice.xml | 35 ++++++ app/src/main/res/menu/menu_album_actions.xml | 3 + .../main/res/menu/menu_album_song_actions.xml | 3 + .../res/menu/menu_artist_album_actions.xml | 3 + .../res/menu/menu_artist_song_actions.xml | 3 + app/src/main/res/menu/menu_parent_actions.xml | 3 + app/src/main/res/menu/menu_parent_detail.xml | 3 + .../main/res/menu/menu_playlist_actions.xml | 15 +++ .../main/res/menu/menu_playlist_detail.xml | 9 ++ .../main/res/menu/menu_selection_actions.xml | 3 + app/src/main/res/menu/menu_song_actions.xml | 3 + app/src/main/res/navigation/nav_main.xml | 20 +++- app/src/main/res/values/strings.xml | 4 + 48 files changed, 669 insertions(+), 86 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{config => fs}/DirectoryAdapter.kt (97%) rename app/src/main/java/org/oxycblt/auxio/music/{config => fs}/MusicDirsDialog.kt (98%) rename app/src/main/java/org/oxycblt/auxio/music/{config => metadata}/SeparatorsDialog.kt (99%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt create mode 100644 app/src/main/res/layout/item_new_playlist_choice.xml create mode 100644 app/src/main/res/menu/menu_playlist_actions.xml create mode 100644 app/src/main/res/menu/menu_playlist_detail.xml diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 70e34cb3e..6d9bc9e9b 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -50,6 +50,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * TODO: Unit testing * TODO: Fix UID naming * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) + * TODO: Add more logging */ @AndroidEntryPoint class MainActivity : AppCompatActivity() { diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 0cb082671..4877ee253 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -135,6 +135,7 @@ class MainFragment : collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) + collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) collectImmediately(playbackModel.song, ::updateSong) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) @@ -261,7 +262,7 @@ class MainFragment : initialNavDestinationChange = true return } - selectionModel.consume() + selectionModel.drop() } private fun handleMainNavigation(action: MainNavigationAction?) { @@ -312,6 +313,15 @@ class MainFragment : } } + private fun handleAddToPlaylist(songs: List?) { + if (songs != null) { + findNavController() + .navigateSafe( + MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray())) + musicModel.songsToAdd.consume() + } + } + private fun handlePlaybackArtistPicker(song: Song?) { if (song != null) { navModel.mainNavigateTo( @@ -430,7 +440,7 @@ class MainFragment : } // Clear out any prior selections. - if (selectionModel.consume().isNotEmpty()) { + if (selectionModel.drop()) { return } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index d6992458d..3f08beaab 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -43,6 +43,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel @@ -61,6 +62,7 @@ class AlbumDetailFragment : private val detailModel: DetailViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() // Information about what album to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an album. @@ -136,6 +138,10 @@ class AlbumDetailFragment : onNavigateToParentArtist() true } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(currentAlbum) + true + } else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 23e1e3456..046c52f29 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -42,6 +42,7 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel @@ -60,6 +61,7 @@ class ArtistDetailFragment : private val detailModel: DetailViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() // Information about what artist to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an artist. @@ -131,6 +133,10 @@ class ArtistDetailFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(currentArtist) + true + } else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 302c3cfbf..eb08d08a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -56,6 +56,7 @@ class GenreDetailFragment : private val detailModel: DetailViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() // Information about what genre to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an genre. @@ -125,6 +126,10 @@ class GenreDetailFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(currentGenre) + true + } else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index a7ae52234..fb3fdd90d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -56,6 +56,7 @@ class PlaylistDetailFragment : private val detailModel: DetailViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel 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. @@ -81,7 +82,7 @@ class PlaylistDetailFragment : // --- UI SETUP --- binding.detailToolbar.apply { - inflateMenu(R.menu.menu_parent_detail) + inflateMenu(R.menu.menu_playlist_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@PlaylistDetailFragment) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt index 541ed30d9..36a30fe24 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt @@ -51,7 +51,7 @@ abstract class DetailHeaderAdapter(), AppBarLayout.OnOffsetChangedListener { override val playbackModel: PlaybackViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels() - private val musicModel: MusicViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index 765a39154..a17172d08 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -56,6 +56,7 @@ class AlbumListFragment : private val homeModel: HomeViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() private val albumAdapter = AlbumAdapter(this) // Save memory by re-using the same formatter and string builder when creating popup text diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index 33de26ea3..7eb5c88a0 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -38,6 +38,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs @@ -58,6 +59,7 @@ class ArtistListFragment : private val homeModel: HomeViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() private val artistAdapter = ArtistAdapter(this) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index eca18c2a2..8b2cab6f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -38,6 +38,7 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs @@ -57,6 +58,7 @@ class GenreListFragment : private val homeModel: HomeViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() private val genreAdapter = GenreAdapter(this) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt index 5afeb7dc6..a41abdd1d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -36,6 +36,7 @@ import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel @@ -50,6 +51,7 @@ class PlaylistListFragment : private val homeModel: HomeViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() private val playlistAdapter = PlaylistAdapter(this) @@ -107,7 +109,7 @@ class PlaylistListFragment : } override fun onOpenMenu(item: Playlist, anchor: View) { - openMusicMenu(anchor, R.menu.menu_parent_actions, item) + openMusicMenu(anchor, R.menu.menu_playlist_actions, item) } private fun updatePlaylists(playlists: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 9b34f8a70..a21a470df 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -39,6 +39,7 @@ import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel @@ -59,6 +60,7 @@ class SongListFragment : private val homeModel: HomeViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() private val songAdapter = SongAdapter(this) // Save memory by re-using the same formatter and string builder when creating popup text diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index f2898f526..bd2f8f1a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -41,6 +41,7 @@ import org.oxycblt.auxio.music.* * @author Alexander Capehart (OxygenCobalt) */ class MusicKeyer : Keyer { + // TODO: Include hashcode of child songs for parents override fun key(data: Music, options: Options) = if (data is Song) { // Group up song covers with album covers for better caching diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt index 8181fbee0..213b28980 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -99,6 +99,9 @@ abstract class ListFragment : R.id.action_go_album -> { navModel.exploreNavigateTo(song.album) } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(song) + } R.id.action_song_detail -> { navModel.mainNavigateTo( MainNavigationAction.Directions( @@ -141,6 +144,9 @@ abstract class ListFragment : R.id.action_go_artist -> { navModel.exploreNavigateToParentArtist(album) } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(album) + } else -> { error("Unexpected menu item selected") } @@ -175,6 +181,9 @@ abstract class ListFragment : playbackModel.addToQueue(artist) requireContext().showToast(R.string.lng_queue_added) } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(artist) + } else -> { error("Unexpected menu item selected") } @@ -209,6 +218,9 @@ abstract class ListFragment : playbackModel.addToQueue(genre) requireContext().showToast(R.string.lng_queue_added) } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(genre) + } else -> { error("Unexpected menu item selected") } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt index c84f3176e..b535feba2 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt @@ -33,6 +33,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * - Adapter-based [SpanSizeLookup] implementation * - Automatic [setHasFixedSize] setup * + * FIXME: Broken span configuration + * * @author Alexander Capehart (OxygenCobalt) */ open class AuxioRecyclerView diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index cc0603b4c..45face4aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -353,6 +353,10 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB /** * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [T] item, for use * in choice dialogs. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Unwind this into specific impls */ class ChoiceViewHolder private constructor(private val binding: ItemPickerChoiceBinding) : diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt index a3012f56b..bcba5195e 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt @@ -23,6 +23,7 @@ import android.view.MenuItem import androidx.appcompat.widget.Toolbar import androidx.viewbinding.ViewBinding import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.showToast @@ -35,6 +36,7 @@ import org.oxycblt.auxio.util.showToast abstract class SelectionFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener { protected abstract val selectionModel: SelectionViewModel + protected abstract val musicModel: MusicViewModel protected abstract val playbackModel: PlaybackViewModel /** @@ -50,7 +52,7 @@ abstract class SelectionFragment : super.onBindingCreated(binding, savedInstanceState) getSelectionToolbar(binding)?.apply { // Add cancel and menu item listeners to manage what occurs with the selection. - setOnSelectionCancelListener { selectionModel.consume() } + setOnSelectionCancelListener { selectionModel.drop() } setOnMenuItemClickListener(this@SelectionFragment) } } @@ -63,21 +65,25 @@ abstract class SelectionFragment : override fun onMenuItemClick(item: MenuItem) = when (item.itemId) { R.id.action_selection_play_next -> { - playbackModel.playNext(selectionModel.consume()) + playbackModel.playNext(selectionModel.take()) requireContext().showToast(R.string.lng_queue_added) true } R.id.action_selection_queue_add -> { - playbackModel.addToQueue(selectionModel.consume()) + playbackModel.addToQueue(selectionModel.take()) requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_selection_playlist_add -> { + musicModel.addToPlaylist(selectionModel.take()) + true + } R.id.action_selection_play -> { - playbackModel.play(selectionModel.consume()) + playbackModel.play(selectionModel.take()) true } R.id.action_selection_shuffle -> { - playbackModel.shuffle(selectionModel.consume()) + playbackModel.shuffle(selectionModel.take()) true } else -> false diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index 42fea7d41..5c772f519 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -31,8 +31,12 @@ import org.oxycblt.auxio.music.* * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.UpdateListener { +class SelectionViewModel +@Inject +constructor( + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings +) : ViewModel(), MusicRepository.UpdateListener { private val _selected = MutableStateFlow(listOf()) /** the currently selected items. These are ordered in earliest selected and latest selected. */ val selected: StateFlow> @@ -80,9 +84,27 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR } /** - * Consume the current selection. This will clear any items that were selected prior. + * Clear the current selection and return it. * - * @return The list of selected items before it was cleared. + * @return A list of [Song]s collated from each item selected. */ - fun consume() = _selected.value.also { _selected.value = listOf() } + fun take() = + _selected.value + .flatMap { + when (it) { + is Song -> listOf(it) + is Album -> musicSettings.albumSongSort.songs(it.songs) + is Artist -> musicSettings.artistSongSort.songs(it.songs) + is Genre -> musicSettings.genreSongSort.songs(it.songs) + is Playlist -> musicSettings.playlistSongSort.songs(it.songs) + } + } + .also { drop() } + + /** + * Clear the current selection. + * + * @return true if the prior selection was non-empty, false otherwise. + */ + fun drop() = _selected.value.isNotEmpty().also { _selected.value = listOf() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index b04f035fc..6ee562a93 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -114,11 +114,19 @@ interface MusicRepository { /** * Create a new [Playlist] of the given [Song]s. * - * @param name The name of the new [Playlist] + * @param name The name of the new [Playlist]. * @param songs The songs to populate the new [Playlist] with. */ fun createPlaylist(name: String, songs: List) + /** + * Add the given [Song]s to a [Playlist]. + * + * @param songs The [Song]s to add to the [Playlist]. + * @param playlist The [Playlist] to add to. + */ + fun addToPlaylist(songs: List, playlist: Playlist) + /** * Request that a music loading operation is started by the current [IndexingWorker]. Does * nothing if one is not available. @@ -255,6 +263,15 @@ constructor( } } + override fun addToPlaylist(songs: List, playlist: Playlist) { + val userLibrary = userLibrary ?: return + userLibrary.addToPlaylist(playlist, songs) + for (listener in updateListeners) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) + } + } + override fun requestIndex(withCache: Boolean) { indexingWorker?.requestIndex(withCache) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 1972209b2..ea487ba46 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -32,8 +32,12 @@ import org.oxycblt.auxio.util.MutableEvent * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class MusicViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { +class MusicViewModel +@Inject +constructor( + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings +) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { private val _indexingState = MutableStateFlow(null) /** The current music loading state, or null if no loading is going on. */ @@ -48,6 +52,10 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos /** Flag for opening a dialog to create a playlist of the given [Song]s. */ val newPlaylistSongs: Event?> = _newPlaylistSongs + private val _songsToAdd = MutableEvent?>() + /** Flag for opening a dialog to add the given [Song]s to a playlist. */ + val songsToAdd: Event?> = _songsToAdd + init { musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -85,23 +93,71 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos } /** - * Create a new generic playlist. This will first open a dialog for the user to make a naming - * choice before committing the playlist to the database. + * Create a new generic [Playlist]. * + * @param name The name of the new [Playlist]. If null, the user will be prompted for one. * @param songs The [Song]s to be contained in the new playlist. */ - fun createPlaylist(songs: List = listOf()) { - _newPlaylistSongs.put(songs) + fun createPlaylist(name: String? = null, songs: List = listOf()) { + if (name != null) { + musicRepository.createPlaylist(name, songs) + } else { + _newPlaylistSongs.put(songs) + } } /** - * Create a new generic playlist. This will immediately commit the playlist to the database. + * Add a [Song] to a [Playlist]. * - * @param name The name of the new playlist. - * @param songs The [Song]s to be contained in the new playlist. + * @param song The [Song] to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. */ - fun createPlaylist(name: String, songs: List = listOf()) { - musicRepository.createPlaylist(name, songs) + fun addToPlaylist(song: Song, playlist: Playlist? = null) { + addToPlaylist(listOf(song), playlist) + } + + /** + * Add an [Album] to a [Playlist]. + * + * @param album The [Album] to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(album: Album, playlist: Playlist? = null) { + addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist) + } + + /** + * Add an [Artist] to a [Playlist]. + * + * @param artist The [Artist] to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(artist: Artist, playlist: Playlist? = null) { + addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist) + } + + /** + * Add a [Genre] to a [Playlist]. + * + * @param genre The [Genre] to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(genre: Genre, playlist: Playlist? = null) { + addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist) + } + + /** + * Add [Song]s to a [Playlist]. + * + * @param songs The [Song]s to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(songs: List, playlist: Playlist? = null) { + if (playlist != null) { + musicRepository.addToPlaylist(songs, playlist) + } else { + _songsToAdd.put(songs) + } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt index f9e7b4229..5913c2b8c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt @@ -16,14 +16,13 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.config +package org.oxycblt.auxio.music.fs import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemMusicDirBinding import org.oxycblt.auxio.list.recycler.DialogRecyclerView -import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater diff --git a/app/src/main/java/org/oxycblt/auxio/music/config/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/config/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt index 28a4960ba..4ecea1336 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/config/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.config +package org.oxycblt.auxio.music.fs import android.content.ActivityNotFoundException import android.net.Uri @@ -35,8 +35,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.fs.Directory -import org.oxycblt.auxio.music.fs.MusicDirectories import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD diff --git a/app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index 2fc2c5c58..c413fa2bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.config +package org.oxycblt.auxio.music.metadata import android.os.Bundle import android.view.LayoutInflater diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt new file mode 100644 index 000000000..5c861bc8a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 Auxio Project + * AddToPlaylistDialog.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogMusicPickerBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.showToast + +/** + * A dialog that allows the user to pick a specific playlist to add song(s) to. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class AddToPlaylistDialog : + ViewBindingDialogFragment(), + ClickableListListener, + NewPlaylistFooterAdapter.Listener { + private val musicModel: MusicViewModel by activityViewModels() + private val pickerModel: PlaylistPickerViewModel by activityViewModels() + // 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: AddToPlaylistDialogArgs by navArgs() + private val choiceAdapter = PlaylistChoiceAdapter(this) + private val footerAdapter = NewPlaylistFooterAdapter(this) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.lbl_playlist_add).setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicPickerBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.pickerChoiceRecycler.apply { + itemAnimator = null + adapter = ConcatAdapter(choiceAdapter, footerAdapter) + } + + // --- VIEWMODEL SETUP --- + pickerModel.setPendingSongs(args.songUids) + collectImmediately(pickerModel.currentPendingSongs, ::updatePendingSongs) + collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices) + } + + override fun onDestroyBinding(binding: DialogMusicPickerBinding) { + super.onDestroyBinding(binding) + binding.pickerChoiceRecycler.adapter = null + } + + override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { + musicModel.addToPlaylist(pickerModel.currentPendingSongs.value ?: return, item.playlist) + pickerModel.confirmPlaylistAddition() + requireContext().showToast(R.string.lng_playlist_added) + findNavController().navigateUp() + } + + override fun onNewPlaylist() { + musicModel.createPlaylist(songs = pickerModel.currentPendingSongs.value ?: return) + } + + private fun updatePendingSongs(songs: List?) { + if (songs == null) { + // No songs to feasibly add to a playlist, leave. + findNavController().navigateUp() + } + } + + private fun updatePlaylistChoices(choices: List) { + choiceAdapter.update(choices, null) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt index f5ce94c87..27d84fbec 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -23,7 +23,6 @@ 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 @@ -32,6 +31,7 @@ import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull @AndroidEntryPoint class NewPlaylistDialog : ViewBindingDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() - private val pickerModel: PlaylistPickerViewModel by viewModels() + private val pickerModel: PlaylistPickerViewModel by activityViewModels() // 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: NewPlaylistDialogArgs by navArgs() @@ -58,7 +58,10 @@ class NewPlaylistDialog : ViewBindingDialogFragment() is ChosenName.Empty -> pendingPlaylist.preferredName else -> throw IllegalStateException() } + // TODO: Navigate to playlist if there are songs in it musicModel.createPlaylist(name, pendingPlaylist.songs) + pickerModel.confirmPlaylistCreation() + requireContext().showToast(R.string.lng_playlist_created) } .setNegativeButton(R.string.lbl_cancel, null) } @@ -69,11 +72,13 @@ class NewPlaylistDialog : ViewBindingDialogFragment() override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) + // --- UI SETUP --- binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) } + // --- VIEWMODEL SETUP --- pickerModel.setPendingPlaylist(requireContext(), args.songUids) collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist) - collectImmediately(pickerModel.chosenName, ::handleChosenName) + collectImmediately(pickerModel.chosenName, ::updateChosenName) } private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) { @@ -85,7 +90,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment() requireBinding().playlistName.hint = pendingPlaylist.preferredName } - private fun handleChosenName(chosenName: ChosenName) { + private fun updateChosenName(chosenName: ChosenName) { (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = chosenName is ChosenName.Valid || chosenName is ChosenName.Empty } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt new file mode 100644 index 000000000..fb7f1a965 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 Auxio Project + * NewPlaylistFooterAdapter.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.databinding.ItemNewPlaylistChoiceBinding +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.util.inflater + +/** + * A purely-visual [RecyclerView.Adapter] that acts as a footer providing a "New Playlist" choice in + * [AddToPlaylistDialog]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class NewPlaylistFooterAdapter(private val listener: Listener) : + RecyclerView.Adapter() { + override fun getItemCount() = 1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + NewPlaylistFooterViewHolder.from(parent) + + override fun onBindViewHolder(holder: NewPlaylistFooterViewHolder, position: Int) { + holder.bind(listener) + } + + /** A listener for [NewPlaylistFooterAdapter] interactions. */ + interface Listener { + /** + * Called when the footer has been pressed, requesting to create a new playlist to add to. + */ + fun onNewPlaylist() + } +} + +/** + * A [RecyclerView.ViewHolder] that displays a "New Playlist" choice in [NewPlaylistFooterAdapter]. + * Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class NewPlaylistFooterViewHolder +private constructor(private val binding: ItemNewPlaylistChoiceBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param listener A [NewPlaylistFooterAdapter.Listener] to bind interactions to. + */ + fun bind(listener: NewPlaylistFooterAdapter.Listener) { + binding.root.setOnClickListener { listener.onNewPlaylist() } + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = + NewPlaylistFooterViewHolder( + ItemNewPlaylistChoiceBinding.inflate(parent.context.inflater)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt new file mode 100644 index 000000000..02a5424e9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistChoiceAdapter.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.view.View +import android.view.ViewGroup +import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.inflater + +/** + * A [FlexibleListAdapter] that displays a list of [PlaylistChoice] options to select from in + * [AddToPlaylistDialog]. + * + * @param listener [ClickableListListener] to bind interactions to. + */ +class PlaylistChoiceAdapter(val listener: ClickableListListener) : + FlexibleListAdapter( + PlaylistChoiceViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PlaylistChoiceViewHolder.from(parent) + + override fun onBindViewHolder(holder: PlaylistChoiceViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } +} + +/** + * A [DialogRecyclerView.ViewHolder] that displays an individual playlist choice. Use [from] to + * create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class PlaylistChoiceViewHolder private constructor(private val binding: ItemPickerChoiceBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + fun bind(choice: PlaylistChoice, listener: ClickableListListener) { + listener.bind(choice, this) + binding.pickerImage.apply { + bind(choice.playlist) + isActivated = choice.alreadyAdded + } + binding.pickerName.text = choice.playlist.name.resolve(binding.context) + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = + PlaylistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: PlaylistChoice, newItem: PlaylistChoice) = + oldItem.playlist.name == newItem.playlist.name && + oldItem.alreadyAdded == newItem.alreadyAdded + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt index 33b9fc28d..8fb9a9eb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt @@ -25,8 +25,11 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song /** @@ -45,11 +48,20 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M val chosenName: StateFlow get() = _chosenName + private val _currentPendingSongs = MutableStateFlow?>(null) + val currentPendingSongs: StateFlow?> + get() = _currentPendingSongs + + private val _playlistChoices = MutableStateFlow>(listOf()) + val playlistChoices: StateFlow> + get() = _playlistChoices + init { musicRepository.addUpdateListener(this) } override fun onMusicChanges(changes: MusicRepository.Changes) { + var refreshChoicesWith: List? = null val deviceLibrary = musicRepository.deviceLibrary if (changes.deviceLibrary && deviceLibrary != null) { _currentPendingPlaylist.value = @@ -58,6 +70,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M pendingPlaylist.preferredName, pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) } + _currentPendingSongs.value = + _currentPendingSongs.value?.let { pendingSongs -> + pendingSongs + .mapNotNull { deviceLibrary.findSong(it.uid) } + .ifEmpty { null } + .also { refreshChoicesWith = it } + } } val chosenName = _chosenName.value @@ -69,7 +88,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M // Nothing to do. } } + refreshChoicesWith = refreshChoicesWith ?: _currentPendingSongs.value } + + refreshChoicesWith?.let(::refreshPlaylistChoices) } override fun onCleared() { @@ -80,7 +102,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * Update the current [PendingPlaylist]. Will do nothing if already equal. * * @param context [Context] required to generate a playlist name. - * @param songUids The list of [Music.UID] representing the songs to be present in the playlist. + * @param songUids The [Music.UID]s of songs to be present in the playlist. */ fun setPendingPlaylist(context: Context, songUids: Array) { if (currentPendingPlaylist.value?.songs?.map { it.uid } == songUids) { @@ -89,8 +111,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } val deviceLibrary = musicRepository.deviceLibrary ?: return val songs = songUids.mapNotNull(deviceLibrary::findSong) - val userLibrary = musicRepository.userLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return var i = 1 while (true) { val possibleName = context.getString(R.string.fmt_def_playlist, i) @@ -123,6 +145,43 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } } } + + /** Confirm the playlist creation process as completed. */ + fun confirmPlaylistCreation() { + // Confirm any playlist additions if needed, as the creation process may have been started + // by it and is still waiting on a result. + confirmPlaylistAddition() + _currentPendingPlaylist.value = null + _chosenName.value = ChosenName.Empty + } + + /** + * Update the current [Song]s that to show playlist add choices for. Will do nothing if already + * equal. + * + * @param songUids The [Music.UID]s of songs to add to a playlist. + */ + fun setPendingSongs(songUids: Array) { + if (currentPendingSongs.value?.map { it.uid } == songUids) return + val deviceLibrary = musicRepository.deviceLibrary ?: return + val songs = songUids.mapNotNull(deviceLibrary::findSong) + _currentPendingSongs.value = songs + refreshPlaylistChoices(songs) + } + + /** Mark the addition process as complete. */ + fun confirmPlaylistAddition() { + _currentPendingSongs.value = null + } + + private fun refreshPlaylistChoices(songs: List) { + val userLibrary = musicRepository.userLibrary ?: return + _playlistChoices.value = + Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { + val songSet = it.songs.toSet() + PlaylistChoice(it, songs.all(songSet::contains)) + } + } } /** @@ -149,3 +208,13 @@ sealed interface ChosenName { /** The current name only consists of whitespace. */ object Blank : ChosenName } + +/** + * An individual [Playlist] choice to add [Song]s to. + * + * @param playlist The [Playlist] represented. + * @param alreadyAdded Whether the songs currently pending addition have already been added to the + * [Playlist]. + * @author Alexander Capehart (OxygenCobalt) + */ +data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index e4ae2ab41..4e5239f7d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -107,7 +107,7 @@ private class UserLibraryImpl( init { // TODO: Actually read playlists - createPlaylist("Playlist 1", deviceLibrary.songs.slice(58..100)) + createPlaylist("Playlist 1", deviceLibrary.songs.slice(58..200)) } override fun findPlaylist(uid: Music.UID) = playlistMap[uid] diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt index b2be5f806..1d3f83543 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt @@ -70,7 +70,7 @@ class NavigateToArtistDialog : } pickerModel.setArtistChoiceUid(args.itemUid) - collectImmediately(pickerModel.currentArtistChoices) { + collectImmediately(pickerModel.artistChoices) { if (it != null) { choiceAdapter.update(it.choices, UpdateInstructions.Replace(0)) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt index 932322c82..b09b74ae9 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt @@ -33,10 +33,10 @@ import org.oxycblt.auxio.music.* @HiltViewModel class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : ViewModel(), MusicRepository.UpdateListener { - private val _currentArtistChoices = MutableStateFlow(null) + private val _artistChoices = MutableStateFlow(null) /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ - val currentArtistChoices: StateFlow - get() = _currentArtistChoices + val artistChoices: StateFlow + get() = _artistChoices init { musicRepository.addUpdateListener(this) @@ -46,8 +46,8 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: if (!changes.deviceLibrary) return val deviceLibrary = musicRepository.deviceLibrary ?: return // Need to sanitize different items depending on the current set of choices. - _currentArtistChoices.value = - when (val choices = _currentArtistChoices.value) { + _artistChoices.value = + when (val choices = _artistChoices.value) { is SongArtistNavigationChoices -> deviceLibrary.findSong(choices.song.uid)?.let { SongArtistNavigationChoices(it) @@ -72,7 +72,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: */ fun setArtistChoiceUid(itemUid: Music.UID) { // Support Songs and Albums, which have parent artists. - _currentArtistChoices.value = + _artistChoices.value = when (val music = musicRepository.find(itemUid)) { is Song -> SongArtistNavigationChoices(music) is Album -> AlbumArtistNavigationChoices(music) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index d94e38621..81ea0d121 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -256,12 +256,11 @@ constructor( fun play(playlist: Playlist) = playImpl(null, playlist, false) /** - * Play a [Music] selection. + * Play a list of [Song]s. * - * @param selection The selection to play. + * @param songs The [Song]s to play. */ - fun play(selection: List) = - playbackManager.play(null, null, selectionToSongs(selection), false) + fun play(songs: List) = playbackManager.play(null, null, songs, false) /** * Shuffle an [Album]. @@ -292,12 +291,11 @@ constructor( fun shuffle(playlist: Playlist) = playImpl(null, playlist, true) /** - * Shuffle a [Music] selection. + * Shuffle a list of [Song]s. * - * @param selection The selection to shuffle. + * @param songs The [Song]s to shuffle. */ - fun shuffle(selection: List) = - playbackManager.play(null, null, selectionToSongs(selection), true) + fun shuffle(songs: List) = playbackManager.play(null, null, songs, true) private fun playImpl( song: Song?, @@ -400,12 +398,12 @@ constructor( } /** - * Add a selection to the top of the queue. + * Add [Song]s to the top of the queue. * - * @param selection The [Music] selection to add. + * @param songs The [Song]s to add. */ - fun playNext(selection: List) { - playbackManager.playNext(selectionToSongs(selection)) + fun playNext(songs: List) { + playbackManager.playNext(songs) } /** @@ -454,12 +452,12 @@ constructor( } /** - * Add a selection to the end of the queue. + * Add [Song]s to the end of the queue. * - * @param selection The [Music] selection to add. + * @param songs The [Song]s to add. */ - fun addToQueue(selection: List) { - playbackManager.addToQueue(selectionToSongs(selection)) + fun addToQueue(songs: List) { + playbackManager.addToQueue(songs) } // --- STATUS FUNCTIONS --- @@ -522,23 +520,4 @@ constructor( onDone(false) } } - - /** - * Convert the given selection to a list of [Song]s. - * - * @param selection The selection of [Music] to convert. - * @return A [Song] list containing the child items of any [MusicParent] instances in the list - * alongside the unchanged [Song]s or the original selection. - */ - private fun selectionToSongs(selection: List): List { - return selection.flatMap { - when (it) { - is Song -> listOf(it) - is Album -> musicSettings.albumSongSort.songs(it.songs) - is Artist -> musicSettings.artistSongSort.songs(it.songs) - is Genre -> musicSettings.genreSongSort.songs(it.songs) - is Playlist -> musicSettings.playlistSongSort.songs(it.songs) - } - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 6bf06014d..7846710b9 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -53,6 +53,7 @@ import org.oxycblt.auxio.util.* class SearchFragment : ListFragment() { override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() private val searchModel: SearchViewModel by viewModels() private val searchAdapter = SearchAdapter(this) @@ -150,7 +151,7 @@ class SearchFragment : ListFragment() { is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item) is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) is Genre -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Playlist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) + is Playlist -> openMusicMenu(anchor, R.menu.menu_playlist_actions, item) } } diff --git a/app/src/main/res/layout/dialog_music_picker.xml b/app/src/main/res/layout/dialog_music_picker.xml index 7137339c7..8c1d82ee3 100644 --- a/app/src/main/res/layout/dialog_music_picker.xml +++ b/app/src/main/res/layout/dialog_music_picker.xml @@ -1,4 +1,5 @@ + + + + + + + + diff --git a/app/src/main/res/menu/menu_album_actions.xml b/app/src/main/res/menu/menu_album_actions.xml index 8fcb05935..7292d9016 100644 --- a/app/src/main/res/menu/menu_album_actions.xml +++ b/app/src/main/res/menu/menu_album_actions.xml @@ -15,4 +15,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_song_actions.xml b/app/src/main/res/menu/menu_album_song_actions.xml index 9bf6ba0a6..256322f3e 100644 --- a/app/src/main/res/menu/menu_album_song_actions.xml +++ b/app/src/main/res/menu/menu_album_song_actions.xml @@ -9,6 +9,9 @@ + diff --git a/app/src/main/res/menu/menu_artist_album_actions.xml b/app/src/main/res/menu/menu_artist_album_actions.xml index 5078496da..c94d6886f 100644 --- a/app/src/main/res/menu/menu_artist_album_actions.xml +++ b/app/src/main/res/menu/menu_artist_album_actions.xml @@ -15,4 +15,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_artist_song_actions.xml b/app/src/main/res/menu/menu_artist_song_actions.xml index 14987e8f5..4b20abd21 100644 --- a/app/src/main/res/menu/menu_artist_song_actions.xml +++ b/app/src/main/res/menu/menu_artist_song_actions.xml @@ -9,6 +9,9 @@ + diff --git a/app/src/main/res/menu/menu_parent_actions.xml b/app/src/main/res/menu/menu_parent_actions.xml index df535f77d..4e6112035 100644 --- a/app/src/main/res/menu/menu_parent_actions.xml +++ b/app/src/main/res/menu/menu_parent_actions.xml @@ -12,4 +12,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_parent_detail.xml b/app/src/main/res/menu/menu_parent_detail.xml index e480d9191..3a2225ea3 100644 --- a/app/src/main/res/menu/menu_parent_detail.xml +++ b/app/src/main/res/menu/menu_parent_detail.xml @@ -6,4 +6,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_actions.xml b/app/src/main/res/menu/menu_playlist_actions.xml new file mode 100644 index 000000000..df535f77d --- /dev/null +++ b/app/src/main/res/menu/menu_playlist_actions.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_detail.xml b/app/src/main/res/menu/menu_playlist_detail.xml new file mode 100644 index 000000000..e480d9191 --- /dev/null +++ b/app/src/main/res/menu/menu_playlist_detail.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_selection_actions.xml b/app/src/main/res/menu/menu_selection_actions.xml index ad023050c..568d04a62 100644 --- a/app/src/main/res/menu/menu_selection_actions.xml +++ b/app/src/main/res/menu/menu_selection_actions.xml @@ -10,6 +10,9 @@ android:id="@+id/action_selection_queue_add" android:title="@string/lbl_queue_add" app:showAsAction="never" /> + + diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 01ca95bc8..c0c921371 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -20,6 +20,9 @@ + @@ -51,6 +54,19 @@ app:argType="org.oxycblt.auxio.music.Music$UID[]" /> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 070b3a927..32f8ba50d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,8 @@ Play next Add to queue + Add to playlist + Go to artist Go to album View properties @@ -160,6 +162,8 @@ Loading your music library… Monitoring your music library for changes… Added to queue + Playlist created + Added to playlist Developed by Alexander Capehart Search your library…