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: Unit testing
* TODO: Fix UID naming * TODO: Fix UID naming
* TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims)
* TODO: Add more logging
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {

View file

@ -135,6 +135,7 @@ class MainFragment :
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@ -261,7 +262,7 @@ class MainFragment :
initialNavDestinationChange = true initialNavDestinationChange = true
return return
} }
selectionModel.consume() selectionModel.drop()
} }
private fun handleMainNavigation(action: MainNavigationAction?) { 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?) { private fun handlePlaybackArtistPicker(song: Song?) {
if (song != null) { if (song != null) {
navModel.mainNavigateTo( navModel.mainNavigateTo(
@ -430,7 +440,7 @@ class MainFragment :
} }
// Clear out any prior selections. // Clear out any prior selections.
if (selectionModel.consume().isNotEmpty()) { if (selectionModel.drop()) {
return return
} }

View file

@ -43,6 +43,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
@ -61,6 +62,7 @@ class AlbumDetailFragment :
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what album to display is initially within the navigation arguments // 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. // as a UID, as that is the only safe way to parcel an album.
@ -136,6 +138,10 @@ class AlbumDetailFragment :
onNavigateToParentArtist() onNavigateToParentArtist()
true true
} }
R.id.action_playlist_add -> {
musicModel.addToPlaylist(currentAlbum)
true
}
else -> false 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.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
@ -60,6 +61,7 @@ class ArtistDetailFragment :
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what artist to display is initially within the navigation arguments // 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. // 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) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_playlist_add -> {
musicModel.addToPlaylist(currentArtist)
true
}
else -> false else -> false
} }
} }

View file

@ -56,6 +56,7 @@ class GenreDetailFragment :
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what genre to display is initially within the navigation arguments // 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. // 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) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_playlist_add -> {
musicModel.addToPlaylist(currentGenre)
true
}
else -> false else -> false
} }
} }

View file

@ -56,6 +56,7 @@ class PlaylistDetailFragment :
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what playlist to display is initially within the navigation arguments // 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. // as a UID, as that is the only safe way to parcel an playlist.
@ -81,7 +82,7 @@ class PlaylistDetailFragment :
// --- UI SETUP --- // --- UI SETUP ---
binding.detailToolbar.apply { binding.detailToolbar.apply {
inflateMenu(R.menu.menu_parent_detail) inflateMenu(R.menu.menu_playlist_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment) setOnMenuItemClickListener(this@PlaylistDetailFragment)
} }

View file

@ -51,7 +51,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER) notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
} }
/** An extended listener for [DetailHeaderAdapter] implementations. */ /** A listener for [DetailHeaderAdapter] implementations. */
interface Listener { interface Listener {
/** /**
* Called when the play button in a detail header is pressed, requesting that the current * 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 { SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null private var storagePermissionLauncher: ActivityResultLauncher<String>? = null

View file

@ -56,6 +56,7 @@ class AlbumListFragment :
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
private val albumAdapter = AlbumAdapter(this) private val albumAdapter = AlbumAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text // 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.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
@ -58,6 +59,7 @@ class ArtistListFragment :
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
private val artistAdapter = ArtistAdapter(this) 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.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
@ -57,6 +58,7 @@ class GenreListFragment :
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
private val genreAdapter = GenreAdapter(this) 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.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
@ -50,6 +51,7 @@ class PlaylistListFragment :
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
private val playlistAdapter = PlaylistAdapter(this) private val playlistAdapter = PlaylistAdapter(this)
@ -107,7 +109,7 @@ class PlaylistListFragment :
} }
override fun onOpenMenu(item: Playlist, anchor: View) { 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>) { 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.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
@ -59,6 +60,7 @@ class SongListFragment :
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
private val songAdapter = SongAdapter(this) private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text // 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicKeyer : Keyer<Music> { class MusicKeyer : Keyer<Music> {
// TODO: Include hashcode of child songs for parents
override fun key(data: Music, options: Options) = override fun key(data: Music, options: Options) =
if (data is Song) { if (data is Song) {
// Group up song covers with album covers for better caching // 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 -> { R.id.action_go_album -> {
navModel.exploreNavigateTo(song.album) navModel.exploreNavigateTo(song.album)
} }
R.id.action_playlist_add -> {
musicModel.addToPlaylist(song)
}
R.id.action_song_detail -> { R.id.action_song_detail -> {
navModel.mainNavigateTo( navModel.mainNavigateTo(
MainNavigationAction.Directions( MainNavigationAction.Directions(
@ -141,6 +144,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
R.id.action_go_artist -> { R.id.action_go_artist -> {
navModel.exploreNavigateToParentArtist(album) navModel.exploreNavigateToParentArtist(album)
} }
R.id.action_playlist_add -> {
musicModel.addToPlaylist(album)
}
else -> { else -> {
error("Unexpected menu item selected") error("Unexpected menu item selected")
} }
@ -175,6 +181,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
playbackModel.addToQueue(artist) playbackModel.addToQueue(artist)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
} }
R.id.action_playlist_add -> {
musicModel.addToPlaylist(artist)
}
else -> { else -> {
error("Unexpected menu item selected") error("Unexpected menu item selected")
} }
@ -209,6 +218,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
playbackModel.addToQueue(genre) playbackModel.addToQueue(genre)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
} }
R.id.action_playlist_add -> {
musicModel.addToPlaylist(genre)
}
else -> { else -> {
error("Unexpected menu item selected") error("Unexpected menu item selected")
} }

View file

@ -33,6 +33,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Adapter-based [SpanSizeLookup] implementation * - Adapter-based [SpanSizeLookup] implementation
* - Automatic [setHasFixedSize] setup * - Automatic [setHasFixedSize] setup
* *
* FIXME: Broken span configuration
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
open class AuxioRecyclerView open class AuxioRecyclerView

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 * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [T] item, for use
* in choice dialogs. Use [from] to create an instance. * in choice dialogs. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Unwind this into specific impls
*/ */
class ChoiceViewHolder<T : Music> class ChoiceViewHolder<T : Music>
private constructor(private val binding: ItemPickerChoiceBinding) : private constructor(private val binding: ItemPickerChoiceBinding) :

View file

@ -23,6 +23,7 @@ import android.view.MenuItem
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
@ -35,6 +36,7 @@ import org.oxycblt.auxio.util.showToast
abstract class SelectionFragment<VB : ViewBinding> : abstract class SelectionFragment<VB : ViewBinding> :
ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener { ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener {
protected abstract val selectionModel: SelectionViewModel protected abstract val selectionModel: SelectionViewModel
protected abstract val musicModel: MusicViewModel
protected abstract val playbackModel: PlaybackViewModel protected abstract val playbackModel: PlaybackViewModel
/** /**
@ -50,7 +52,7 @@ abstract class SelectionFragment<VB : ViewBinding> :
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
getSelectionToolbar(binding)?.apply { getSelectionToolbar(binding)?.apply {
// Add cancel and menu item listeners to manage what occurs with the selection. // Add cancel and menu item listeners to manage what occurs with the selection.
setOnSelectionCancelListener { selectionModel.consume() } setOnSelectionCancelListener { selectionModel.drop() }
setOnMenuItemClickListener(this@SelectionFragment) setOnMenuItemClickListener(this@SelectionFragment)
} }
} }
@ -63,21 +65,25 @@ abstract class SelectionFragment<VB : ViewBinding> :
override fun onMenuItemClick(item: MenuItem) = override fun onMenuItemClick(item: MenuItem) =
when (item.itemId) { when (item.itemId) {
R.id.action_selection_play_next -> { R.id.action_selection_play_next -> {
playbackModel.playNext(selectionModel.consume()) playbackModel.playNext(selectionModel.take())
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_selection_queue_add -> { R.id.action_selection_queue_add -> {
playbackModel.addToQueue(selectionModel.consume()) playbackModel.addToQueue(selectionModel.take())
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_selection_playlist_add -> {
musicModel.addToPlaylist(selectionModel.take())
true
}
R.id.action_selection_play -> { R.id.action_selection_play -> {
playbackModel.play(selectionModel.consume()) playbackModel.play(selectionModel.take())
true true
} }
R.id.action_selection_shuffle -> { R.id.action_selection_shuffle -> {
playbackModel.shuffle(selectionModel.consume()) playbackModel.shuffle(selectionModel.take())
true true
} }
else -> false else -> false

View file

@ -31,8 +31,12 @@ import org.oxycblt.auxio.music.*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@HiltViewModel @HiltViewModel
class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) : class SelectionViewModel
ViewModel(), MusicRepository.UpdateListener { @Inject
constructor(
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings
) : ViewModel(), MusicRepository.UpdateListener {
private val _selected = MutableStateFlow(listOf<Music>()) private val _selected = MutableStateFlow(listOf<Music>())
/** the currently selected items. These are ordered in earliest selected and latest selected. */ /** the currently selected items. These are ordered in earliest selected and latest selected. */
val selected: StateFlow<List<Music>> 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. * 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. * @param songs The songs to populate the new [Playlist] with.
*/ */
fun createPlaylist(name: String, songs: List<Song>) 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 * Request that a music loading operation is started by the current [IndexingWorker]. Does
* nothing if one is not available. * 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) { override fun requestIndex(withCache: Boolean) {
indexingWorker?.requestIndex(withCache) indexingWorker?.requestIndex(withCache)
} }

View file

@ -32,8 +32,12 @@ import org.oxycblt.auxio.util.MutableEvent
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@HiltViewModel @HiltViewModel
class MusicViewModel @Inject constructor(private val musicRepository: MusicRepository) : class MusicViewModel
ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { @Inject
constructor(
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
private val _indexingState = MutableStateFlow<IndexingState?>(null) private val _indexingState = MutableStateFlow<IndexingState?>(null)
/** The current music loading state, or null if no loading is going on. */ /** 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. */ /** Flag for opening a dialog to create a playlist of the given [Song]s. */
val newPlaylistSongs: Event<List<Song>?> = _newPlaylistSongs val newPlaylistSongs: Event<List<Song>?> = _newPlaylistSongs
private val _songsToAdd = MutableEvent<List<Song>?>()
/** Flag for opening a dialog to add the given [Song]s to a playlist. */
val songsToAdd: Event<List<Song>?> = _songsToAdd
init { init {
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this) musicRepository.addIndexingListener(this)
@ -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 * Create a new generic [Playlist].
* choice before committing the playlist to the database.
* *
* @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. * @param songs The [Song]s to be contained in the new playlist.
*/ */
fun createPlaylist(songs: List<Song> = listOf()) { fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
_newPlaylistSongs.put(songs) 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 song The [Song] to add to the [Playlist].
* @param songs The [Song]s to be contained in the new 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()) { fun addToPlaylist(song: Song, playlist: Playlist? = null) {
musicRepository.createPlaylist(name, songs) 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/>. * 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.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemMusicDirBinding import org.oxycblt.auxio.databinding.ItemMusicDirBinding
import org.oxycblt.auxio.list.recycler.DialogRecyclerView 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.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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.content.ActivityNotFoundException
import android.net.Uri import android.net.Uri
@ -35,8 +35,6 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.MusicSettings 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.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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.os.Bundle
import android.view.LayoutInflater 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.appcompat.app.AlertDialog
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint 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.music.MusicViewModel
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
@AndroidEntryPoint @AndroidEntryPoint
class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>() { class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>() {
private val musicModel: MusicViewModel by activityViewModels() 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 // 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. // as UIDs, as that is the only safe way to parcel playlist information.
private val args: NewPlaylistDialogArgs by navArgs() private val args: NewPlaylistDialogArgs by navArgs()
@ -58,7 +58,10 @@ class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>()
is ChosenName.Empty -> pendingPlaylist.preferredName is ChosenName.Empty -> pendingPlaylist.preferredName
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
// TODO: Navigate to playlist if there are songs in it
musicModel.createPlaylist(name, pendingPlaylist.songs) musicModel.createPlaylist(name, pendingPlaylist.songs)
pickerModel.confirmPlaylistCreation()
requireContext().showToast(R.string.lng_playlist_created)
} }
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
} }
@ -69,11 +72,13 @@ class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>()
override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) } binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) }
// --- VIEWMODEL SETUP ---
pickerModel.setPendingPlaylist(requireContext(), args.songUids) pickerModel.setPendingPlaylist(requireContext(), args.songUids)
collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist) collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist)
collectImmediately(pickerModel.chosenName, ::handleChosenName) collectImmediately(pickerModel.chosenName, ::updateChosenName)
} }
private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) { private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) {
@ -85,7 +90,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>()
requireBinding().playlistName.hint = pendingPlaylist.preferredName requireBinding().playlistName.hint = pendingPlaylist.preferredName
} }
private fun handleChosenName(chosenName: ChosenName) { private fun updateChosenName(chosenName: ChosenName) {
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
chosenName is ChosenName.Valid || chosenName is ChosenName.Empty 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.R 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.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
/** /**
@ -45,11 +48,20 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
val chosenName: StateFlow<ChosenName> val chosenName: StateFlow<ChosenName>
get() = _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 { init {
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
var refreshChoicesWith: List<Song>? = null
val deviceLibrary = musicRepository.deviceLibrary val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) { if (changes.deviceLibrary && deviceLibrary != null) {
_currentPendingPlaylist.value = _currentPendingPlaylist.value =
@ -58,6 +70,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
pendingPlaylist.preferredName, pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) 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 val chosenName = _chosenName.value
@ -69,7 +88,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
// Nothing to do. // Nothing to do.
} }
} }
refreshChoicesWith = refreshChoicesWith ?: _currentPendingSongs.value
} }
refreshChoicesWith?.let(::refreshPlaylistChoices)
} }
override fun onCleared() { 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. * Update the current [PendingPlaylist]. Will do nothing if already equal.
* *
* @param context [Context] required to generate a playlist name. * @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>) { fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) {
if (currentPendingPlaylist.value?.songs?.map { it.uid } == songUids) { 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 deviceLibrary = musicRepository.deviceLibrary ?: return
val songs = songUids.mapNotNull(deviceLibrary::findSong) val songs = songUids.mapNotNull(deviceLibrary::findSong)
val userLibrary = musicRepository.userLibrary ?: return
val userLibrary = musicRepository.userLibrary ?: return
var i = 1 var i = 1
while (true) { while (true) {
val possibleName = context.getString(R.string.fmt_def_playlist, i) 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. */ /** The current name only consists of whitespace. */
object Blank : ChosenName 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 { init {
// TODO: Actually read playlists // 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] override fun findPlaylist(uid: Music.UID) = playlistMap[uid]

View file

@ -70,7 +70,7 @@ class NavigateToArtistDialog :
} }
pickerModel.setArtistChoiceUid(args.itemUid) pickerModel.setArtistChoiceUid(args.itemUid)
collectImmediately(pickerModel.currentArtistChoices) { collectImmediately(pickerModel.artistChoices) {
if (it != null) { if (it != null) {
choiceAdapter.update(it.choices, UpdateInstructions.Replace(0)) choiceAdapter.update(it.choices, UpdateInstructions.Replace(0))
} else { } else {

View file

@ -33,10 +33,10 @@ import org.oxycblt.auxio.music.*
@HiltViewModel @HiltViewModel
class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
ViewModel(), MusicRepository.UpdateListener { 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. */ /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */
val currentArtistChoices: StateFlow<ArtistNavigationChoices?> val artistChoices: StateFlow<ArtistNavigationChoices?>
get() = _currentArtistChoices get() = _artistChoices
init { init {
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
@ -46,8 +46,8 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
if (!changes.deviceLibrary) return if (!changes.deviceLibrary) return
val deviceLibrary = musicRepository.deviceLibrary ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
// Need to sanitize different items depending on the current set of choices. // Need to sanitize different items depending on the current set of choices.
_currentArtistChoices.value = _artistChoices.value =
when (val choices = _currentArtistChoices.value) { when (val choices = _artistChoices.value) {
is SongArtistNavigationChoices -> is SongArtistNavigationChoices ->
deviceLibrary.findSong(choices.song.uid)?.let { deviceLibrary.findSong(choices.song.uid)?.let {
SongArtistNavigationChoices(it) SongArtistNavigationChoices(it)
@ -72,7 +72,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
*/ */
fun setArtistChoiceUid(itemUid: Music.UID) { fun setArtistChoiceUid(itemUid: Music.UID) {
// Support Songs and Albums, which have parent artists. // Support Songs and Albums, which have parent artists.
_currentArtistChoices.value = _artistChoices.value =
when (val music = musicRepository.find(itemUid)) { when (val music = musicRepository.find(itemUid)) {
is Song -> SongArtistNavigationChoices(music) is Song -> SongArtistNavigationChoices(music)
is Album -> AlbumArtistNavigationChoices(music) is Album -> AlbumArtistNavigationChoices(music)

View file

@ -256,12 +256,11 @@ constructor(
fun play(playlist: Playlist) = playImpl(null, playlist, false) 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>) = fun play(songs: List<Song>) = playbackManager.play(null, null, songs, false)
playbackManager.play(null, null, selectionToSongs(selection), false)
/** /**
* Shuffle an [Album]. * Shuffle an [Album].
@ -292,12 +291,11 @@ constructor(
fun shuffle(playlist: Playlist) = playImpl(null, playlist, true) 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>) = fun shuffle(songs: List<Song>) = playbackManager.play(null, null, songs, true)
playbackManager.play(null, null, selectionToSongs(selection), true)
private fun playImpl( private fun playImpl(
song: Song?, 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>) { fun playNext(songs: List<Song>) {
playbackManager.playNext(selectionToSongs(selection)) 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>) { fun addToQueue(songs: List<Song>) {
playbackManager.addToQueue(selectionToSongs(selection)) playbackManager.addToQueue(songs)
} }
// --- STATUS FUNCTIONS --- // --- STATUS FUNCTIONS ---
@ -522,23 +520,4 @@ constructor(
onDone(false) 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>() { class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels()
private val searchModel: SearchViewModel by viewModels() private val searchModel: SearchViewModel by viewModels()
private val searchAdapter = SearchAdapter(this) 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 Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item)
is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item)
is Genre -> 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"?> <?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" <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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" 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 <item
android:id="@+id/action_go_artist" android:id="@+id/action_go_artist"
android:title="@string/lbl_go_artist" /> android:title="@string/lbl_go_artist" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
</menu> </menu>

View file

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

View file

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

View file

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

View file

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

View file

@ -6,4 +6,7 @@
<item <item
android:id="@+id/action_queue_add" android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" /> android:title="@string/lbl_queue_add" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
</menu> </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:id="@+id/action_selection_queue_add"
android:title="@string/lbl_queue_add" android:title="@string/lbl_queue_add"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_selection_playlist_add"
android:title="@string/lbl_playlist_add" />
<item <item
android:id="@+id/action_selection_play" android:id="@+id/action_selection_play"
android:title="@string/lbl_play_selected" android:title="@string/lbl_play_selected"

View file

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

View file

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

View file

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