music: add ability to import into playlists

Add a menu option that allows you to import a playlist file into an
existing playlist.

This is useful for keeping Auxio playlists up to date with a remote
source.
This commit is contained in:
Alexander Capehart 2023-12-23 20:47:21 -07:00
parent c9b1ab9068
commit 21970349cc
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 118 additions and 45 deletions

View file

@ -273,6 +273,7 @@ class AlbumDetailFragment :
decision.songs.map { it.uid }.toTypedArray())
}
is PlaylistDecision.New,
is PlaylistDecision.Import,
is PlaylistDecision.Rename,
is PlaylistDecision.Delete,
is PlaylistDecision.Export -> error("Unexpected playlist decision $decision")

View file

@ -276,6 +276,7 @@ class ArtistDetailFragment :
decision.songs.map { it.uid }.toTypedArray())
}
is PlaylistDecision.New,
is PlaylistDecision.Import,
is PlaylistDecision.Rename,
is PlaylistDecision.Export,
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")

View file

@ -269,6 +269,7 @@ class GenreDetailFragment :
decision.songs.map { it.uid }.toTypedArray())
}
is PlaylistDecision.New,
is PlaylistDecision.Import,
is PlaylistDecision.Rename,
is PlaylistDecision.Export,
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")

View file

@ -21,6 +21,8 @@ package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@ -48,12 +50,14 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
@ -80,6 +84,8 @@ class PlaylistDetailFragment :
private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var editNavigationListener: DialogAwareNavigationListener? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -99,6 +105,17 @@ class PlaylistDetailFragment :
editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit)
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
logW("No URI returned from file picker")
return@registerForActivityResult
}
logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
@ -320,6 +337,16 @@ class PlaylistDetailFragment :
if (decision == null) return
val directions =
when (decision) {
is PlaylistDecision.Import -> {
logD("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
}
.launch(M3U.MIME_TYPE)
musicModel.playlistDecision.consume()
return
}
is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}")
PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid)

View file

@ -102,8 +102,7 @@ class HomeFragment :
private val detailModel: DetailViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var createDocumentLauncher: ActivityResultLauncher<String>? = null
private var pendingExportPlaylist: Playlist? = null
private var pendingImportTarget: Playlist? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -140,25 +139,7 @@ class HomeFragment :
}
logD("Received playlist URI $uri")
musicModel.importPlaylist(uri)
}
createDocumentLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument(M3U.MIME_TYPE)) { uri
->
if (uri == null) {
logW("No URI returned from file picker")
return@registerForActivityResult
}
val playlist = pendingExportPlaylist
if (playlist == null) {
logW("No playlist to export")
return@registerForActivityResult
}
logD("Received playlist URI $uri")
musicModel.exportPlaylist(playlist, uri)
musicModel.importPlaylist(uri, pendingImportTarget)
}
// --- UI SETUP ---
@ -209,10 +190,7 @@ class HomeFragment :
// re-creating the ViewPager.
setupPager(binding)
binding.homeShuffleFab.setOnClickListener {
logD("Shuffling")
playbackModel.shuffleAll()
}
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
@ -318,7 +296,7 @@ class HomeFragment :
}
R.id.action_import_playlist -> {
logD("Importing playlist")
getContentLauncher?.launch(M3U.MIME_TYPE)
musicModel.importPlaylist()
}
else -> {}
}
@ -494,6 +472,16 @@ class HomeFragment :
logD("Creating new playlist")
HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray())
}
is PlaylistDecision.Import -> {
logD("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
}
.launch(M3U.MIME_TYPE)
musicModel.playlistDecision.consume()
return
}
is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}")
HomeFragmentDirections.renamePlaylist(decision.playlist.uid)
@ -513,7 +501,6 @@ class HomeFragment :
}
}
findNavController().navigateSafe(directions)
musicModel.playlistDecision.consume()
}
private fun handlePlaylistError(error: PlaylistError?) {

View file

@ -288,7 +288,7 @@ class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
R.id.action_play_next,
R.id.action_queue_add,
R.id.action_playlist_add,
R.id.action_playlist_export,
R.id.action_export,
R.id.action_share)
} else {
setOf()
@ -321,7 +321,8 @@ class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_rename -> musicModel.renamePlaylist(menu.playlist)
R.id.action_playlist_export -> musicModel.exportPlaylist(menu.playlist)
R.id.action_import -> musicModel.importPlaylist(target = menu.playlist)
R.id.action_export -> musicModel.exportPlaylist(menu.playlist)
R.id.action_delete -> musicModel.deletePlaylist(menu.playlist)
R.id.action_share -> requireContext().share(menu.playlist)
else -> error("Unexpected menu item $item")

View file

@ -137,10 +137,14 @@ constructor(
/**
* Import a playlist from a file [Uri]. Errors pushed to [importError].
*
* @param uri The [Uri] of the file to import.
* @param uri The [Uri] of the file to import. If null, the user will be prompted with a file
* picker.
* @param target The [Playlist] to import to. If null, a new playlist will be created. Note the
* [Playlist] will not be renamed to the name of the imported playlist.
* @see ExternalPlaylistManager
*/
fun importPlaylist(uri: Uri) =
fun importPlaylist(uri: Uri? = null, target: Playlist? = null) {
if (uri != null) {
viewModelScope.launch(Dispatchers.IO) {
val importedPlaylist = externalPlaylistManager.import(uri)
if (importedPlaylist == null) {
@ -157,8 +161,17 @@ constructor(
}
// TODO Require the user to name it something else if the name is a duplicate of
// a prior playlist
if (target !== null) {
musicRepository.rewritePlaylist(target, songs)
} else {
createPlaylist(importedPlaylist.name, songs)
}
}
} else {
logD("Launching import picker")
_playlistDecision.put(PlaylistDecision.Import(target))
}
}
/**
* Export a [Playlist] to a file [Uri]. Errors pushed to [exportError].
@ -304,6 +317,14 @@ sealed interface PlaylistDecision {
*/
data class New(val songs: List<Song>) : PlaylistDecision
/**
* Navigate to a file picker to import a playlist from.
*
* @param target The [Playlist] to import to. If null, then the file imported will create a new
* playlist.
*/
data class Import(val target: Playlist?) : PlaylistDecision
/**
* Navigate to a dialog that allows a user to rename an existing [Playlist].
*

View file

@ -23,6 +23,8 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isInvisible
import androidx.core.view.postDelayed
import androidx.core.widget.addTextChangedListener
@ -51,6 +53,7 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
@ -58,6 +61,7 @@ import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.setFullWidthLookup
@ -77,6 +81,8 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
private val searchAdapter = SearchAdapter(this)
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
private var imm: InputMethodManager? = null
private var launchedKeyboard = false
@ -98,6 +104,19 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
logW("No URI returned from file picker")
return@registerForActivityResult
}
logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
// --- UI SETUP ---
binding.searchNormalToolbar.apply {
// Initialize the current filtering mode.
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
@ -287,6 +306,16 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
if (decision == null) return
val directions =
when (decision) {
is PlaylistDecision.Import -> {
logD("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
}
.launch(M3U.MIME_TYPE)
musicModel.playlistDecision.consume()
return
}
is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}")
SearchFragmentDirections.renamePlaylist(decision.playlist.uid)

View file

@ -24,6 +24,10 @@
android:id="@+id/action_rename"
android:icon="@drawable/ic_edit_24"
android:title="@string/lbl_rename" />
<item
android:id="@+id/action_import"
android:icon="@drawable/ic_import_24"
android:title="@string/lbl_import" />
<item
android:id="@+id/action_export"
android:icon="@drawable/ic_save_24"

View file

@ -90,6 +90,7 @@
<string name="lbl_new_playlist">New playlist</string>
<string name="lbl_empty_playlist">Empty playlist</string>
<string name="lbl_import_playlist">Imported playlist</string>
<string name="lbl_import">Import</string>
<string name="lbl_export">Export</string>
<string name="lbl_export_playlist">Export playlist</string>
<string name="lbl_rename">Rename</string>