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()) decision.songs.map { it.uid }.toTypedArray())
} }
is PlaylistDecision.New, is PlaylistDecision.New,
is PlaylistDecision.Import,
is PlaylistDecision.Rename, is PlaylistDecision.Rename,
is PlaylistDecision.Delete, is PlaylistDecision.Delete,
is PlaylistDecision.Export -> error("Unexpected playlist decision $decision") is PlaylistDecision.Export -> error("Unexpected playlist decision $decision")

View file

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

View file

@ -269,6 +269,7 @@ class GenreDetailFragment :
decision.songs.map { it.uid }.toTypedArray()) decision.songs.map { it.uid }.toTypedArray())
} }
is PlaylistDecision.New, is PlaylistDecision.New,
is PlaylistDecision.Import,
is PlaylistDecision.Rename, is PlaylistDecision.Rename,
is PlaylistDecision.Export, is PlaylistDecision.Export,
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") 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.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs 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.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
@ -80,6 +84,8 @@ class PlaylistDetailFragment :
private val playlistListAdapter = PlaylistDetailListAdapter(this) private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null private var touchHelper: ItemTouchHelper? = null
private var editNavigationListener: DialogAwareNavigationListener? = null private var editNavigationListener: DialogAwareNavigationListener? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -99,6 +105,17 @@ class PlaylistDetailFragment :
editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit) 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 --- // --- UI SETUP ---
binding.detailNormalToolbar.apply { binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
@ -320,6 +337,16 @@ class PlaylistDetailFragment :
if (decision == null) return if (decision == null) return
val directions = val directions =
when (decision) { 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 -> { is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid)

View file

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

View file

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

View file

@ -137,28 +137,41 @@ constructor(
/** /**
* Import a playlist from a file [Uri]. Errors pushed to [importError]. * 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 * @see ExternalPlaylistManager
*/ */
fun importPlaylist(uri: Uri) = fun importPlaylist(uri: Uri? = null, target: Playlist? = null) {
viewModelScope.launch(Dispatchers.IO) { if (uri != null) {
val importedPlaylist = externalPlaylistManager.import(uri) viewModelScope.launch(Dispatchers.IO) {
if (importedPlaylist == null) { val importedPlaylist = externalPlaylistManager.import(uri)
_playlistError.put(PlaylistError.ImportFailed) if (importedPlaylist == null) {
return@launch _playlistError.put(PlaylistError.ImportFailed)
} return@launch
}
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
if (songs.isEmpty()) { if (songs.isEmpty()) {
_playlistError.put(PlaylistError.ImportFailed) _playlistError.put(PlaylistError.ImportFailed)
return@launch return@launch
}
// 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)
}
} }
// TODO Require the user to name it something else if the name is a duplicate of } else {
// a prior playlist logD("Launching import picker")
createPlaylist(importedPlaylist.name, songs) _playlistDecision.put(PlaylistDecision.Import(target))
} }
}
/** /**
* Export a [Playlist] to a file [Uri]. Errors pushed to [exportError]. * 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 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]. * 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.MenuItem
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager 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.isInvisible
import androidx.core.view.postDelayed import androidx.core.view.postDelayed
import androidx.core.widget.addTextChangedListener 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.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect 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.context
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
@ -77,6 +81,8 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
private val searchAdapter = SearchAdapter(this) private val searchAdapter = SearchAdapter(this)
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
private var imm: InputMethodManager? = null private var imm: InputMethodManager? = null
private var launchedKeyboard = false private var launchedKeyboard = false
@ -98,6 +104,19 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
imm = binding.context.getSystemServiceCompat(InputMethodManager::class) 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 { binding.searchNormalToolbar.apply {
// Initialize the current filtering mode. // Initialize the current filtering mode.
menu.findItem(searchModel.getFilterOptionId()).isChecked = true menu.findItem(searchModel.getFilterOptionId()).isChecked = true
@ -287,6 +306,16 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
if (decision == null) return if (decision == null) return
val directions = val directions =
when (decision) { 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 -> { is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
SearchFragmentDirections.renamePlaylist(decision.playlist.uid) SearchFragmentDirections.renamePlaylist(decision.playlist.uid)

View file

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

View file

@ -90,6 +90,7 @@
<string name="lbl_new_playlist">New playlist</string> <string name="lbl_new_playlist">New playlist</string>
<string name="lbl_empty_playlist">Empty playlist</string> <string name="lbl_empty_playlist">Empty playlist</string>
<string name="lbl_import_playlist">Imported 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">Export</string>
<string name="lbl_export_playlist">Export playlist</string> <string name="lbl_export_playlist">Export playlist</string>
<string name="lbl_rename">Rename</string> <string name="lbl_rename">Rename</string>