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:
parent
c9b1ab9068
commit
21970349cc
10 changed files with 118 additions and 45 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue