music: add playlist addition

Implement playlist addition and it's UI flow.
This commit is contained in:
Alexander Capehart 2023-05-13 18:54:55 -06:00
parent 4fe91c25e3
commit 7435165929
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
48 changed files with 669 additions and 86 deletions

View file

@ -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() {

View file

@ -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<Song>?) {
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
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -51,7 +51,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
}
/** An extended listener for [DetailHeaderAdapter] implementations. */
/** A listener for [DetailHeaderAdapter] implementations. */
interface Listener {
/**
* Called when the play button in a detail header is pressed, requesting that the current

View file

@ -68,8 +68,8 @@ class HomeFragment :
SelectionFragment<FragmentHomeBinding>(), 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<String>? = null

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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<Playlist>) {

View file

@ -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

View file

@ -41,6 +41,7 @@ import org.oxycblt.auxio.music.*
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicKeyer : Keyer<Music> {
// 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

View file

@ -99,6 +99,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
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<in T : Music, VB : ViewBinding> :
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<in T : Music, VB : ViewBinding> :
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<in T : Music, VB : ViewBinding> :
playbackModel.addToQueue(genre)
requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_playlist_add -> {
musicModel.addToPlaylist(genre)
}
else -> {
error("Unexpected menu item selected")
}

View file

@ -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

View file

@ -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<T : Music>
private constructor(private val binding: ItemPickerChoiceBinding) :

View file

@ -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<VB : ViewBinding> :
ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener {
protected abstract val selectionModel: SelectionViewModel
protected abstract val musicModel: MusicViewModel
protected abstract val playbackModel: PlaybackViewModel
/**
@ -50,7 +52,7 @@ abstract class SelectionFragment<VB : ViewBinding> :
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<VB : ViewBinding> :
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

View file

@ -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<Music>())
/** the currently selected items. These are ordered in earliest selected and latest selected. */
val selected: StateFlow<List<Music>>
@ -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() }
}

View file

@ -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<Song>)
/**
* 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<Song>, 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<Song>, 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)
}

View file

@ -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<IndexingState?>(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<List<Song>?> = _newPlaylistSongs
private val _songsToAdd = MutableEvent<List<Song>?>()
/** Flag for opening a dialog to add the given [Song]s to a playlist. */
val songsToAdd: Event<List<Song>?> = _songsToAdd
init {
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<Song> = listOf()) {
_newPlaylistSongs.put(songs)
fun createPlaylist(name: String? = null, songs: List<Song> = 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<Song> = 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<Song>, playlist: Playlist? = null) {
if (playlist != null) {
musicRepository.addToPlaylist(songs, playlist)
} else {
_songsToAdd.put(songs)
}
}
/**

View file

@ -16,14 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.config
package org.oxycblt.auxio.music.metadata
import android.os.Bundle
import android.view.LayoutInflater

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.picker
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.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<DialogMusicPickerBinding>(),
ClickableListListener<PlaylistChoice>,
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<Song>?) {
if (songs == null) {
// No songs to feasibly add to a playlist, leave.
findNavController().navigateUp()
}
}
private fun updatePlaylistChoices(choices: List<PlaylistChoice>) {
choiceAdapter.update(choices, null)
}
}

View file

@ -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<DialogPlaylistNameBinding>() {
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<DialogPlaylistNameBinding>()
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<DialogPlaylistNameBinding>()
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<DialogPlaylistNameBinding>()
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
}

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<PlaylistChoice>) :
FlexibleListAdapter<PlaylistChoice, PlaylistChoiceViewHolder>(
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<PlaylistChoice>) {
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<PlaylistChoice>() {
override fun areContentsTheSame(oldItem: PlaylistChoice, newItem: PlaylistChoice) =
oldItem.playlist.name == newItem.playlist.name &&
oldItem.alreadyAdded == newItem.alreadyAdded
}
}
}

View file

@ -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<ChosenName>
get() = _chosenName
private val _currentPendingSongs = MutableStateFlow<List<Song>?>(null)
val currentPendingSongs: StateFlow<List<Song>?>
get() = _currentPendingSongs
private val _playlistChoices = MutableStateFlow<List<PlaylistChoice>>(listOf())
val playlistChoices: StateFlow<List<PlaylistChoice>>
get() = _playlistChoices
init {
musicRepository.addUpdateListener(this)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
var refreshChoicesWith: List<Song>? = 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<Music.UID>) {
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<Music.UID>) {
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<Song>) {
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

View file

@ -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]

View file

@ -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 {

View file

@ -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<ArtistNavigationChoices?>(null)
private val _artistChoices = MutableStateFlow<ArtistNavigationChoices?>(null)
/** The current set of [Artist] choices to show in the picker, or null if to show nothing. */
val currentArtistChoices: StateFlow<ArtistNavigationChoices?>
get() = _currentArtistChoices
val artistChoices: StateFlow<ArtistNavigationChoices?>
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)

View file

@ -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<Music>) =
playbackManager.play(null, null, selectionToSongs(selection), false)
fun play(songs: List<Song>) = 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<Music>) =
playbackManager.play(null, null, selectionToSongs(selection), true)
fun shuffle(songs: List<Song>) = 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<Music>) {
playbackManager.playNext(selectionToSongs(selection))
fun playNext(songs: List<Song>) {
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<Music>) {
playbackManager.addToQueue(selectionToSongs(selection))
fun addToQueue(songs: List<Song>) {
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<Music>): List<Song> {
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)
}
}
}
}

View file

@ -53,6 +53,7 @@ import org.oxycblt.auxio.util.*
class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
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<Music, FragmentSearchBinding>() {
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)
}
}

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- TODO: Rename picker usages to choice usages now that the former is used more generally -->
<org.oxycblt.auxio.list.recycler.DialogRecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingStart="@dimen/spacing_large"
android:paddingTop="@dimen/spacing_mid_medium"
android:paddingEnd="@dimen/spacing_large"
android:paddingBottom="@dimen/spacing_mid_medium">
<org.oxycblt.auxio.image.ImageGroup
android:id="@+id/picker_image"
style="@style/Widget.Auxio.Image.Small"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:staticIcon="@drawable/ic_add_24" />
<TextView
android:id="@+id/picker_name"
style="@style/Widget.Auxio.TextView.Item.Primary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
android:textColor="@color/sel_selectable_text_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/picker_image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
android:text="@string/lbl_new_playlist" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -15,4 +15,7 @@
<item
android:id="@+id/action_go_artist"
android:title="@string/lbl_go_artist" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
</menu>

View file

@ -9,6 +9,9 @@
<item
android:id="@+id/action_go_artist"
android:title="@string/lbl_go_artist" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
<item
android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail" />

View file

@ -15,4 +15,7 @@
<item
android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
</menu>

View file

@ -9,6 +9,9 @@
<item
android:id="@+id/action_go_album"
android:title="@string/lbl_go_album" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
<item
android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail" />

View file

@ -12,4 +12,7 @@
<item
android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
</menu>

View file

@ -6,4 +6,7 @@
<item
android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
</menu>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_play"
android:title="@string/lbl_play" />
<item
android:id="@+id/action_shuffle"
android:title="@string/lbl_shuffle" />
<item
android:id="@+id/action_play_next"
android:title="@string/lbl_play_next" />
<item
android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" />
</menu>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_play_next"
android:title="@string/lbl_play_next" />
<item
android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" />
</menu>

View file

@ -10,6 +10,9 @@
android:id="@+id/action_selection_queue_add"
android:title="@string/lbl_queue_add"
app:showAsAction="never" />
<item
android:id="@+id/action_selection_playlist_add"
android:title="@string/lbl_playlist_add" />
<item
android:id="@+id/action_selection_play"
android:title="@string/lbl_play_selected"

View file

@ -12,6 +12,9 @@
<item
android:id="@+id/action_go_album"
android:title="@string/lbl_go_album" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
<item
android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail" />

View file

@ -20,6 +20,9 @@
<action
android:id="@+id/action_new_playlist"
app:destination="@id/new_playlist_dialog" />
<action
android:id="@+id/action_add_to_playlist"
app:destination="@id/add_to_playlist_dialog" />
<action
android:id="@+id/action_pick_navigation_artist"
app:destination="@id/navigate_to_artist_dialog" />
@ -51,6 +54,19 @@
app:argType="org.oxycblt.auxio.music.Music$UID[]" />
</dialog>
<dialog
android:id="@+id/add_to_playlist_dialog"
android:name="org.oxycblt.auxio.music.picker.AddToPlaylistDialog"
android:label="new_playlist_dialog"
tools:layout="@layout/dialog_playlist_name">
<argument
android:name="songUids"
app:argType="org.oxycblt.auxio.music.Music$UID[]" />
<action
android:id="@+id/action_new_playlist"
app:destination="@id/new_playlist_dialog" />
</dialog>
<dialog
android:id="@+id/navigate_to_artist_dialog"
android:name="org.oxycblt.auxio.navigation.picker.NavigateToArtistDialog"
@ -155,12 +171,12 @@
tools:layout="@layout/dialog_pre_amp" />
<dialog
android:id="@+id/music_dirs_dialog"
android:name="org.oxycblt.auxio.music.config.MusicDirsDialog"
android:name="org.oxycblt.auxio.music.fs.MusicDirsDialog"
android:label="music_dirs_dialog"
tools:layout="@layout/dialog_music_dirs" />
<dialog
android:id="@+id/separators_dialog"
android:name="org.oxycblt.auxio.music.config.SeparatorsDialog"
android:name="org.oxycblt.auxio.music.metadata.SeparatorsDialog"
android:label="music_dirs_dialog"
tools:layout="@layout/dialog_separators" />

View file

@ -111,6 +111,8 @@
<string name="lbl_play_next">Play next</string>
<string name="lbl_queue_add">Add to queue</string>
<string name="lbl_playlist_add">Add to playlist</string>
<string name="lbl_go_artist">Go to artist</string>
<string name="lbl_go_album">Go to album</string>
<string name="lbl_song_detail">View properties</string>
@ -160,6 +162,8 @@
<string name="lng_indexing">Loading your music library…</string>
<string name="lng_observing">Monitoring your music library for changes…</string>
<string name="lng_queue_added">Added to queue</string>
<string name="lng_playlist_created">Playlist created</string>
<string name="lng_playlist_added">Added to playlist</string>
<string name="lng_author">Developed by Alexander Capehart</string>
<!-- As in music library -->
<string name="lng_search_library">Search your library…</string>