Skip to content

Commit

Permalink
music: add ability to import into playlists
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
OxygenCobalt committed Dec 24, 2023
1 parent c9b1ab9 commit 2197034
Show file tree
Hide file tree
Showing 10 changed files with 118 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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() }
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 14 additions & 27 deletions app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -318,7 +296,7 @@ class HomeFragment :
}
R.id.action_import_playlist -> {
logD("Importing playlist")
getContentLauncher?.launch(M3U.MIME_TYPE)
musicModel.importPlaylist()
}
else -> {}
}
Expand Down Expand Up @@ -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)
Expand All @@ -513,7 +501,6 @@ class HomeFragment :
}
}
findNavController().navigateSafe(directions)
musicModel.playlistDecision.consume()
}

private fun handlePlaylistError(error: PlaylistError?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
53 changes: 37 additions & 16 deletions app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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].
*
Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,13 +53,15 @@ 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
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

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/menu/playlist.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down

0 comments on commit 2197034

Please sign in to comment.