music: rename playlist when reimporting

When reimporting an M3U file into a playlist, if the name differs, then
initiate a rename dialog so the user has a choice on whether they want
to use the new name or not.

This does kinda desecrate the "Rename" decision a bit, but it's still
to the user the same.
This commit is contained in:
Alexander Capehart 2024-01-01 20:21:39 -07:00
parent 9ad11ec5aa
commit 0675ce8a03
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 142 additions and 60 deletions

View file

@ -352,7 +352,11 @@ class PlaylistDetailFragment :
} }
is PlaylistDecision.Rename -> { is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) PlaylistDetailFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
decision.applySongs.map { it.uid }.toTypedArray(),
decision.reason)
} }
is PlaylistDecision.Export -> { is PlaylistDecision.Export -> {
logD("Exporting ${decision.playlist}") logD("Exporting ${decision.playlist}")

View file

@ -487,7 +487,11 @@ class HomeFragment :
} }
is PlaylistDecision.Rename -> { is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
HomeFragmentDirections.renamePlaylist(decision.playlist.uid) HomeFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
decision.applySongs.map { it.uid }.toTypedArray(),
decision.reason)
} }
is PlaylistDecision.Export -> { is PlaylistDecision.Export -> {
logD("Exporting ${decision.playlist}") logD("Exporting ${decision.playlist}")

View file

@ -348,6 +348,7 @@ interface Genre : MusicParent {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Playlist : MusicParent { interface Playlist : MusicParent {
override val name: Name.Known
override val songs: List<Song> override val songs: List<Song>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long

View file

@ -145,7 +145,7 @@ constructor(
} }
/** /**
* Import a playlist from a file [Uri]. Errors pushed to [importError]. * Import a playlist from a file [Uri]. Errors pushed to [playlistMessage].
* *
* @param uri The [Uri] of the file to import. If null, the user will be prompted with a file * @param uri The [Uri] of the file to import. If null, the user will be prompted with a file
* picker. * picker.
@ -173,8 +173,17 @@ constructor(
} }
if (target !== null) { if (target !== null) {
if (importedPlaylist.name != null && importedPlaylist.name != target.name.raw) {
_playlistDecision.put(
PlaylistDecision.Rename(
target,
importedPlaylist.name,
songs,
PlaylistDecision.Rename.Reason.IMPORT))
} else {
musicRepository.rewritePlaylist(target, songs) musicRepository.rewritePlaylist(target, songs)
_playlistMessage.put(PlaylistMessage.ImportSuccess) _playlistMessage.put(PlaylistMessage.ImportSuccess)
}
} else { } else {
_playlistDecision.put( _playlistDecision.put(
PlaylistDecision.New( PlaylistDecision.New(
@ -188,7 +197,7 @@ constructor(
} }
/** /**
* Export a [Playlist] to a file [Uri]. Errors pushed to [exportError]. * Export a [Playlist] to a file [Uri]. Errors pushed to [playlistMessage].
* *
* @param playlist The [Playlist] to export. * @param playlist The [Playlist] to export.
* @param uri The [Uri] to export to. If null, the user will be prompted for one. * @param uri The [Uri] to export to. If null, the user will be prompted for one.
@ -214,17 +223,24 @@ constructor(
* *
* @param playlist The [Playlist] to rename, * @param playlist The [Playlist] to rename,
* @param name The new name of the [Playlist]. If null, the user will be prompted for a name. * @param name The new name of the [Playlist]. If null, the user will be prompted for a name.
* @param reason The reason why the playlist is being renamed. For all intensive purposes, you * @param applySongs The songs to apply to the playlist after renaming. If empty, no songs will
* be applied. This argument is internal and does not need to be specified in normal use.
* @param reason The reason why the playlist is being renamed. This argument is internal and
* does not need to be specified in normal use.
*/ */
fun renamePlaylist( fun renamePlaylist(
playlist: Playlist, playlist: Playlist,
name: String? = null, name: String? = null,
applySongs: List<Song> = listOf(),
reason: PlaylistDecision.Rename.Reason = PlaylistDecision.Rename.Reason.ACTION reason: PlaylistDecision.Rename.Reason = PlaylistDecision.Rename.Reason.ACTION
) { ) {
if (name != null) { if (name != null) {
logD("Renaming $playlist to $name") logD("Renaming $playlist to $name")
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
musicRepository.renamePlaylist(playlist, name) musicRepository.renamePlaylist(playlist, name)
if (applySongs.isNotEmpty()) {
musicRepository.rewritePlaylist(playlist, applySongs)
}
val message = val message =
when (reason) { when (reason) {
PlaylistDecision.Rename.Reason.ACTION -> PlaylistMessage.RenameSuccess PlaylistDecision.Rename.Reason.ACTION -> PlaylistMessage.RenameSuccess
@ -234,7 +250,7 @@ constructor(
} }
} else { } else {
logD("Launching rename dialog for $playlist") logD("Launching rename dialog for $playlist")
_playlistDecision.put(PlaylistDecision.Rename(playlist, reason)) _playlistDecision.put(PlaylistDecision.Rename(playlist, null, applySongs, reason))
} }
} }
@ -243,7 +259,8 @@ constructor(
* *
* @param playlist The playlist to delete. * @param playlist The playlist to delete.
* @param rude Whether to immediately delete the playlist or prompt the user first. This should * @param rude Whether to immediately delete the playlist or prompt the user first. This should
* be false at almost all times. * be false at almost all times. This argument is internal and does not need to be specified
* in normal use.
*/ */
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
if (rude) { if (rude) {
@ -375,7 +392,12 @@ sealed interface PlaylistDecision {
* *
* @param playlist The playlist to act on. * @param playlist The playlist to act on.
*/ */
data class Rename(val playlist: Playlist, val reason: Reason) : PlaylistDecision { data class Rename(
val playlist: Playlist,
val template: String?,
val applySongs: List<Song>,
val reason: Reason
) : PlaylistDecision {
enum class Reason { enum class Reason {
ACTION, ACTION,
IMPORT IMPORT

View file

@ -31,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -52,9 +53,14 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNameBi
override fun onConfigDialog(builder: AlertDialog.Builder) { override fun onConfigDialog(builder: AlertDialog.Builder) {
builder builder
.setTitle(R.string.lbl_new_playlist) .setTitle(
when (args.reason) {
PlaylistDecision.New.Reason.NEW,
PlaylistDecision.New.Reason.ADD -> R.string.lbl_new_playlist
PlaylistDecision.New.Reason.IMPORT -> R.string.lbl_import_playlist
})
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPendingPlaylist.value) val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPendingNewPlaylist.value)
val name = val name =
when (val chosenName = pickerModel.chosenName.value) { when (val chosenName = pickerModel.chosenName.value) {
is ChosenName.Valid -> chosenName.value is ChosenName.Valid -> chosenName.value
@ -84,27 +90,29 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNameBi
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
musicModel.playlistDecision.consume() musicModel.playlistDecision.consume()
pickerModel.setPendingPlaylist(requireContext(), args.songUids, args.reason) pickerModel.setPendingPlaylist(requireContext(), args.songUids, args.template, args.reason)
if (!initializedField) {
collectImmediately(pickerModel.currentPendingNewPlaylist, ::updatePendingPlaylist)
collectImmediately(pickerModel.chosenName, ::updateChosenName)
}
private fun updatePendingPlaylist(pendingNewPlaylist: PendingNewPlaylist?) {
if (pendingNewPlaylist == null) {
logD("No playlist to create, leaving")
findNavController().navigateUp()
return
}
val binding = requireBinding()
if (pendingNewPlaylist.template != null) {
if (initializedField) return
initializedField = true initializedField = true
// Need to convert args.existingName to an Editable // Need to convert args.existingName to an Editable
if (args.template != null) { if (args.template != null) {
binding.playlistName.text = EDITABLE_FACTORY.newEditable(args.template) binding.playlistName.text = EDITABLE_FACTORY.newEditable(args.template)
} }
} else {
binding.playlistName.hint = pendingNewPlaylist.preferredName
} }
collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist)
collectImmediately(pickerModel.chosenName, ::updateChosenName)
}
private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) {
if (pendingPlaylist == null) {
logD("No playlist to create, leaving")
findNavController().navigateUp()
return
}
requireBinding().playlistName.hint = pendingPlaylist.preferredName
} }
private fun updateChosenName(chosenName: ChosenName) { private fun updateChosenName(chosenName: ChosenName) {

View file

@ -45,15 +45,15 @@ import org.oxycblt.auxio.util.logW
@HiltViewModel @HiltViewModel
class PlaylistPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : class PlaylistPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
ViewModel(), MusicRepository.UpdateListener { ViewModel(), MusicRepository.UpdateListener {
private val _currentPendingPlaylist = MutableStateFlow<PendingPlaylist?>(null) private val _currentPendingNewPlaylist = MutableStateFlow<PendingNewPlaylist?>(null)
/** A new [Playlist] having it's name chosen by the user. Null if none yet. */ /** A new [Playlist] having it's name chosen by the user. Null if none yet. */
val currentPendingPlaylist: StateFlow<PendingPlaylist?> val currentPendingNewPlaylist: StateFlow<PendingNewPlaylist?>
get() = _currentPendingPlaylist get() = _currentPendingNewPlaylist
private val _currentPlaylistToRename = MutableStateFlow<Playlist?>(null) private val _currentPendingRenamePlaylist = MutableStateFlow<PendingRenamePlaylist?>(null)
/** An existing [Playlist] that is being renamed. Null if none yet. */ /** An existing [Playlist] that is being renamed. Null if none yet. */
val currentPlaylistToRename: StateFlow<Playlist?> val currentPendingRenamePlaylist: StateFlow<PendingRenamePlaylist?>
get() = _currentPlaylistToRename get() = _currentPendingRenamePlaylist
private val _currentPlaylistToExport = MutableStateFlow<Playlist?>(null) private val _currentPlaylistToExport = MutableStateFlow<Playlist?>(null)
/** An existing [Playlist] that is being exported. Null if none yet. */ /** An existing [Playlist] that is being exported. Null if none yet. */
@ -71,7 +71,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
get() = _currentPlaylistToDelete get() = _currentPlaylistToDelete
private val _chosenName = MutableStateFlow<ChosenName>(ChosenName.Empty) private val _chosenName = MutableStateFlow<ChosenName>(ChosenName.Empty)
/** The users chosen name for [currentPendingPlaylist] or [currentPlaylistToRename]. */ /** The users chosen name for [currentPendingNewPlaylist] or [currentPendingRenamePlaylist]. */
val chosenName: StateFlow<ChosenName> val chosenName: StateFlow<ChosenName>
get() = _chosenName get() = _chosenName
@ -93,14 +93,15 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
var refreshChoicesWith: List<Song>? = null 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 = _currentPendingNewPlaylist.value =
_currentPendingPlaylist.value?.let { pendingPlaylist -> _currentPendingNewPlaylist.value?.let { pendingPlaylist ->
PendingPlaylist( PendingNewPlaylist(
pendingPlaylist.preferredName, pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }, pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) },
pendingPlaylist.template,
pendingPlaylist.reason) pendingPlaylist.reason)
} }
logD("Updated pending playlist: ${_currentPendingPlaylist.value?.preferredName}") logD("Updated pending playlist: ${_currentPendingNewPlaylist.value?.preferredName}")
_currentSongsToAdd.value = _currentSongsToAdd.value =
_currentSongsToAdd.value?.let { pendingSongs -> _currentSongsToAdd.value?.let { pendingSongs ->
@ -141,7 +142,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
/** /**
* Set a new [currentPendingPlaylist] from a new batch of pending [Song] [Music.UID]s. * Set a new [currentPendingNewPlaylist] from a new batch of pending [Song] [Music.UID]s.
* *
* @param context [Context] required to generate a playlist name. * @param context [Context] required to generate a playlist name.
* @param songUids The [Music.UID]s of songs to be present in the playlist. * @param songUids The [Music.UID]s of songs to be present in the playlist.
@ -150,6 +151,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
fun setPendingPlaylist( fun setPendingPlaylist(
context: Context, context: Context,
songUids: Array<Music.UID>, songUids: Array<Music.UID>,
template: String?,
reason: PlaylistDecision.New.Reason reason: PlaylistDecision.New.Reason
) { ) {
logD("Opening ${songUids.size} songs to create a playlist from") logD("Opening ${songUids.size} songs to create a playlist from")
@ -173,9 +175,9 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
possibleName possibleName
} }
_currentPendingPlaylist.value = _currentPendingNewPlaylist.value =
if (possibleName != null && songs != null) { if (possibleName != null && songs != null) {
PendingPlaylist(possibleName, songs, reason) PendingNewPlaylist(possibleName, songs, template, reason)
} else { } else {
logW("Given song UIDs to create were invalid") logW("Given song UIDs to create were invalid")
null null
@ -183,15 +185,27 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
/** /**
* Set a new [currentPlaylistToRename] from a [Playlist] [Music.UID]. * Set a new [currentPendingRenamePlaylist] from a [Playlist] [Music.UID].
* *
* @param playlistUid The [Music.UID]s of the [Playlist] to rename. * @param playlistUid The [Music.UID]s of the [Playlist] to rename.
*/ */
fun setPlaylistToRename(playlistUid: Music.UID) { fun setPlaylistToRename(
playlistUid: Music.UID,
applySongUids: Array<Music.UID>,
template: String?,
reason: PlaylistDecision.Rename.Reason
) {
logD("Opening playlist $playlistUid to rename") logD("Opening playlist $playlistUid to rename")
_currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid) val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToDelete.value == null) { val applySongs =
musicRepository.deviceLibrary?.let { applySongUids.mapNotNull(it::findSong) }
_currentPendingRenamePlaylist.value =
if (playlist != null && applySongs != null) {
PendingRenamePlaylist(playlist, applySongs, template, reason)
} else {
logW("Given playlist UID to rename was invalid") logW("Given playlist UID to rename was invalid")
null
} }
} }
@ -223,7 +237,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
/** /**
* Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID]. * Set a new [currentPendingNewPlaylist] from a new [Playlist] [Music.UID].
* *
* @param playlistUid The [Music.UID] of the [Playlist] to delete. * @param playlistUid The [Music.UID] of the [Playlist] to delete.
*/ */
@ -301,16 +315,24 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
* Represents a playlist that will be created as soon as a name is chosen. * Represents a playlist that will be created as soon as a name is chosen.
* *
* @param preferredName The name to be used by default if no other name is chosen. * @param preferredName The name to be used by default if no other name is chosen.
* @param songs The [Song]s to be contained in the [PendingPlaylist] * @param songs The [Song]s to be contained in the [PendingNewPlaylist]
* @param reason The reason the playlist is being created. * @param reason The reason the playlist is being created.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class PendingPlaylist( data class PendingNewPlaylist(
val preferredName: String, val preferredName: String,
val songs: List<Song>, val songs: List<Song>,
val template: String?,
val reason: PlaylistDecision.New.Reason val reason: PlaylistDecision.New.Reason
) )
data class PendingRenamePlaylist(
val playlist: Playlist,
val applySongs: List<Song>,
val template: String?,
val reason: PlaylistDecision.Rename.Reason
)
/** /**
* Represents the (processed) user input from the playlist naming dialogs. * Represents the (processed) user input from the playlist naming dialogs.
* *

View file

@ -30,7 +30,6 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -54,9 +53,14 @@ class RenamePlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNam
builder builder
.setTitle(R.string.lbl_rename_playlist) .setTitle(R.string.lbl_rename_playlist)
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
val playlist = unlikelyToBeNull(pickerModel.currentPlaylistToRename.value) val pendingRenamePlaylist =
unlikelyToBeNull(pickerModel.currentPendingRenamePlaylist.value)
val chosenName = pickerModel.chosenName.value as ChosenName.Valid val chosenName = pickerModel.chosenName.value as ChosenName.Valid
musicModel.renamePlaylist(playlist, chosenName.value) musicModel.renamePlaylist(
pendingRenamePlaylist.playlist,
chosenName.value,
pendingRenamePlaylist.applySongs,
pendingRenamePlaylist.reason)
findNavController().navigateUp() findNavController().navigateUp()
} }
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
@ -73,20 +77,23 @@ class RenamePlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNam
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
musicModel.playlistDecision.consume() musicModel.playlistDecision.consume()
pickerModel.setPlaylistToRename(args.playlistUid) pickerModel.setPlaylistToRename(
collectImmediately(pickerModel.currentPlaylistToRename, ::updatePlaylistToRename) args.playlistUid, args.applySongUids, args.template, args.reason)
collectImmediately(pickerModel.currentPendingRenamePlaylist, ::updatePlaylistToRename)
collectImmediately(pickerModel.chosenName, ::updateChosenName) collectImmediately(pickerModel.chosenName, ::updateChosenName)
} }
private fun updatePlaylistToRename(playlist: Playlist?) { private fun updatePlaylistToRename(pendingRenamePlaylist: PendingRenamePlaylist?) {
if (playlist == null) { if (pendingRenamePlaylist == null) {
// Nothing to rename anymore. // Nothing to rename anymore.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
if (!initializedField) { if (!initializedField) {
val default = playlist.name.resolve(requireContext()) val default =
pendingRenamePlaylist.template
?: pendingRenamePlaylist.playlist.name.resolve(requireContext())
logD("Name input is not initialized, setting to $default") logD("Name input is not initialized, setting to $default")
requireBinding().playlistName.setText(default) requireBinding().playlistName.setText(default)
initializedField = true initializedField = true

View file

@ -321,7 +321,11 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
} }
is PlaylistDecision.Rename -> { is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
SearchFragmentDirections.renamePlaylist(decision.playlist.uid) SearchFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
decision.applySongs.map { it.uid }.toTypedArray(),
decision.reason)
} }
is PlaylistDecision.Delete -> { is PlaylistDecision.Delete -> {
logD("Deleting ${decision.playlist}") logD("Deleting ${decision.playlist}")

View file

@ -72,7 +72,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/playback_controls_container" app:layout_constraintBottom_toTopOf="@+id/playback_controls_container"
app:layout_constraintEnd_toEndOf="@+id/playback_cover_pager" app:layout_constraintEnd_toEndOf="@+id/playback_info_container"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />

View file

@ -430,6 +430,16 @@
<argument <argument
android:name="playlistUid" android:name="playlistUid"
app:argType="org.oxycblt.auxio.music.Music$UID" /> app:argType="org.oxycblt.auxio.music.Music$UID" />
<argument
android:name="template"
app:argType="string"
app:nullable="true" />
<argument
android:name="applySongUids"
app:argType="org.oxycblt.auxio.music.Music$UID[]" />
<argument
android:name="reason"
app:argType="org.oxycblt.auxio.music.PlaylistDecision$Rename$Reason" />
</dialog> </dialog>
<dialog <dialog