diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 16aed1ce6..b2bef79d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -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") diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index d217806e7..f45fa5d34 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -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") diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index aaa31f8c1..b5f5550fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -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") diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index a312079ef..aa9039881 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -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? = 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) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 2ce4dc107..cb88c6241 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -102,8 +102,7 @@ class HomeFragment : private val detailModel: DetailViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null private var getContentLauncher: ActivityResultLauncher? = null - private var createDocumentLauncher: ActivityResultLauncher? = 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?) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index 3df0f5541..238d315da 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -288,7 +288,7 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() { 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() { 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") diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 45e8ff499..c440d6e9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -137,28 +137,41 @@ 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) = - viewModelScope.launch(Dispatchers.IO) { - val importedPlaylist = externalPlaylistManager.import(uri) - if (importedPlaylist == null) { - _playlistError.put(PlaylistError.ImportFailed) - return@launch - } + fun importPlaylist(uri: Uri? = null, target: Playlist? = null) { + if (uri != null) { + viewModelScope.launch(Dispatchers.IO) { + val importedPlaylist = externalPlaylistManager.import(uri) + if (importedPlaylist == null) { + _playlistError.put(PlaylistError.ImportFailed) + return@launch + } - val deviceLibrary = musicRepository.deviceLibrary ?: return@launch - val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) + val deviceLibrary = musicRepository.deviceLibrary ?: return@launch + val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) - if (songs.isEmpty()) { - _playlistError.put(PlaylistError.ImportFailed) - return@launch + if (songs.isEmpty()) { + _playlistError.put(PlaylistError.ImportFailed) + 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 - // a prior playlist - 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) : 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]. * diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index c115a156b..e99fd3bfd 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -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() { override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() private val searchAdapter = SearchAdapter(this) + private var getContentLauncher: ActivityResultLauncher? = null + private var pendingImportTarget: Playlist? = null private var imm: InputMethodManager? = null private var launchedKeyboard = false @@ -98,6 +104,19 @@ class SearchFragment : ListFragment() { 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() { 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) diff --git a/app/src/main/res/menu/playlist.xml b/app/src/main/res/menu/playlist.xml index afa071eab..c56c2f799 100644 --- a/app/src/main/res/menu/playlist.xml +++ b/app/src/main/res/menu/playlist.xml @@ -24,6 +24,10 @@ android:id="@+id/action_rename" android:icon="@drawable/ic_edit_24" android:title="@string/lbl_rename" /> + New playlist Empty playlist Imported playlist + Import Export Export playlist Rename