music: add playlist addition
Implement playlist addition and it's UI flow.
This commit is contained in:
parent
4fe91c25e3
commit
7435165929
48 changed files with 669 additions and 86 deletions
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) :
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
35
app/src/main/res/layout/item_new_playlist_choice.xml
Normal file
35
app/src/main/res/layout/item_new_playlist_choice.xml
Normal 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>
|
|
@ -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>
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
15
app/src/main/res/menu/menu_playlist_actions.xml
Normal file
15
app/src/main/res/menu/menu_playlist_actions.xml
Normal 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>
|
9
app/src/main/res/menu/menu_playlist_detail.xml
Normal file
9
app/src/main/res/menu/menu_playlist_detail.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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" />
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue