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 3fd4a6963..16aed1ce6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -274,7 +274,8 @@ class AlbumDetailFragment : } is PlaylistDecision.New, is PlaylistDecision.Rename, - is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") + is PlaylistDecision.Delete, + is PlaylistDecision.Export -> error("Unexpected playlist decision $decision") } findNavController().navigateSafe(directions) } 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 b0bc09386..d217806e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -277,6 +277,7 @@ class ArtistDetailFragment : } is PlaylistDecision.New, is PlaylistDecision.Rename, + is PlaylistDecision.Export, is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") } findNavController().navigateSafe(directions) 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 522ebbfa6..aaa31f8c1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -270,6 +270,7 @@ class GenreDetailFragment : } is PlaylistDecision.New, is PlaylistDecision.Rename, + is PlaylistDecision.Export, is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") } findNavController().navigateSafe(directions) 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 540017724..a312079ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -324,6 +324,10 @@ class PlaylistDetailFragment : logD("Renaming ${decision.playlist}") PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) } + is PlaylistDecision.Export -> { + logD("Exporting ${decision.playlist}") + PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid) + } is PlaylistDecision.Delete -> { logD("Deleting ${decision.playlist}") PlaylistDetailFragmentDirections.deletePlaylist(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 8d75a5a1e..2ce4dc107 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -68,8 +68,11 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.NoAudioPermissionException import org.oxycblt.auxio.music.NoMusicException import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO +import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.PlaylistDecision +import org.oxycblt.auxio.music.PlaylistError import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.external.M3U import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -98,7 +101,9 @@ class HomeFragment : private val homeModel: HomeViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null - private var filePickerLauncher: ActivityResultLauncher? = null + private var getContentLauncher: ActivityResultLauncher? = null + private var createDocumentLauncher: ActivityResultLauncher? = null + private var pendingExportPlaylist: Playlist? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -127,7 +132,7 @@ class HomeFragment : musicModel.refresh() } - filePickerLauncher = + getContentLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri == null) { logW("No URI returned from file picker") @@ -138,6 +143,24 @@ class HomeFragment : 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) + } + // --- UI SETUP --- binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeNormalToolbar.apply { @@ -210,7 +233,7 @@ class HomeFragment : collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(musicModel.playlistDecision.flow, ::handleDecision) - collectImmediately(musicModel.importError.flow, ::handleImportError) + collectImmediately(musicModel.playlistError.flow, ::handlePlaylistError) collect(detailModel.toShow.flow, ::handleShow) } @@ -295,7 +318,7 @@ class HomeFragment : } R.id.action_import_playlist -> { logD("Importing playlist") - filePickerLauncher?.launch("audio/x-mpegurl") + getContentLauncher?.launch(M3U.MIME_TYPE) } else -> {} } @@ -475,6 +498,10 @@ class HomeFragment : logD("Renaming ${decision.playlist}") HomeFragmentDirections.renamePlaylist(decision.playlist.uid) } + is PlaylistDecision.Export -> { + logD("Exporting ${decision.playlist}") + HomeFragmentDirections.exportPlaylist(decision.playlist.uid) + } is PlaylistDecision.Delete -> { logD("Deleting ${decision.playlist}") HomeFragmentDirections.deletePlaylist(decision.playlist.uid) @@ -486,13 +513,22 @@ class HomeFragment : } } findNavController().navigateSafe(directions) + musicModel.playlistDecision.consume() } - private fun handleImportError(flag: Unit?) { - if (flag != null) { - requireContext().showToast(R.string.err_import_failed) - musicModel.importError.consume() + private fun handlePlaylistError(error: PlaylistError?) { + when (error) { + is PlaylistError.ImportFailed -> { + requireContext().showToast(R.string.err_import_failed) + musicModel.importError.consume() + } + is PlaylistError.ExportFailed -> { + requireContext().showToast(R.string.err_export_failed) + musicModel.importError.consume() + } + null -> {} } + musicModel.playlistError.consume() } private fun updateFab(songs: List, isFastScrolling: Boolean) { 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 a7fadce1f..3df0f5541 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,6 +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_share) } else { setOf() @@ -320,6 +321,7 @@ 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_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 e31908e92..45e8ff499 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.music.external.ExportConfig import org.oxycblt.auxio.music.external.ExternalPlaylistManager import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -64,11 +65,20 @@ constructor( val playlistDecision: Event get() = _playlistDecision + private val _playlistError = MutableEvent() + val playlistError: Event + get() = _playlistError + private val _importError = MutableEvent() /** Flag for when playlist importing failed. Consume this and show an error if active. */ val importError: Event get() = _importError + private val _exportError = MutableEvent() + /** Flag for when playlist exporting failed. Consume this and show an error if active. */ + val exportError: Event + get() = _exportError + init { musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -134,7 +144,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { val importedPlaylist = externalPlaylistManager.import(uri) if (importedPlaylist == null) { - _importError.put(Unit) + _playlistError.put(PlaylistError.ImportFailed) return@launch } @@ -142,13 +152,34 @@ constructor( val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) if (songs.isEmpty()) { - _importError.put(Unit) + _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 createPlaylist(importedPlaylist.name, songs) } + /** + * Export a [Playlist] to a file [Uri]. Errors pushed to [exportError]. + * + * @param playlist The [Playlist] to export. + * @param uri The [Uri] to export to. If null, the user will be prompted for one. + */ + fun exportPlaylist(playlist: Playlist, uri: Uri? = null, config: ExportConfig? = null) { + if (uri != null && config != null) { + logD("Exporting playlist to $uri") + viewModelScope.launch(Dispatchers.IO) { + if (!externalPlaylistManager.export(playlist, uri, config)) { + _playlistError.put(PlaylistError.ExportFailed) + } + } + } else { + logD("Launching export dialog") + _playlistDecision.put(PlaylistDecision.Export(playlist)) + } + } + /** * Rename the given playlist. * @@ -280,6 +311,13 @@ sealed interface PlaylistDecision { */ data class Rename(val playlist: Playlist) : PlaylistDecision + /** + * Navigate to a dialog that allows the user to export a [Playlist]. + * + * @param playlist The [Playlist] to export. + */ + data class Export(val playlist: Playlist) : PlaylistDecision + /** * Navigate to a dialog that confirms the deletion of an existing [Playlist]. * @@ -294,3 +332,9 @@ sealed interface PlaylistDecision { */ data class Add(val songs: List) : PlaylistDecision } + +sealed interface PlaylistError { + data object ImportFailed : PlaylistError + + data object ExportFailed : PlaylistError +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt index f01bf6da0..6a5ac5e00 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt @@ -1,4 +1,154 @@ +/* + * Copyright (c) 2023 Auxio Project + * ExportPlaylistDialog.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.oxycblt.auxio.music.decision -class ExportPlaylistDialog { -} \ No newline at end of file +import android.os.Bundle +import android.view.LayoutInflater +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.external.ExportConfig +import org.oxycblt.auxio.music.external.M3U +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * A dialog that allows the user to configure how a playlist will be exported to a file. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class ExportPlaylistDialog : ViewBindingMaterialDialogFragment() { + private val musicModel: MusicViewModel by activityViewModels() + private val pickerModel: PlaylistPickerViewModel by viewModels() + private var createDocumentLauncher: ActivityResultLauncher? = null + // Information about what playlist to name for is initially within the navigation arguments + // as UIDs, as that is the only safe way to parcel playlist information. + private val args: ExportPlaylistDialogArgs by navArgs() + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_export_playlist) + .setPositiveButton(R.string.lbl_export, null) + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogPlaylistExportBinding.inflate(inflater) + + override fun onBindingCreated( + binding: DialogPlaylistExportBinding, + savedInstanceState: Bundle? + ) { + // --- UI SETUP --- + createDocumentLauncher = + registerForActivityResult(ActivityResultContracts.CreateDocument(M3U.MIME_TYPE)) { uri + -> + if (uri == null) { + logW("No URI returned from file picker") + return@registerForActivityResult + } + + val playlist = pickerModel.currentPlaylistToExport.value + if (playlist == null) { + logW("No playlist to export") + findNavController().navigateUp() + return@registerForActivityResult + } + + logD("Received playlist URI $uri") + musicModel.exportPlaylist(playlist, uri, pickerModel.currentExportConfig.value) + findNavController().navigateUp() + } + + binding.exportPathsGroup.addOnButtonCheckedListener { group, checkedId, isChecked -> + if (!isChecked) return@addOnButtonCheckedListener + val current = pickerModel.currentExportConfig.value + pickerModel.setExportConfig( + current.copy(absolute = checkedId == R.id.export_absolute_paths)) + } + + binding.exportWindowsPaths.setOnClickListener { _ -> + val current = pickerModel.currentExportConfig.value + logD("change") + pickerModel.setExportConfig(current.copy(windowsPaths = !current.windowsPaths)) + } + + // --- VIEWMODEL SETUP --- + musicModel.playlistDecision.consume() + pickerModel.setPlaylistToExport(args.playlistUid) + collectImmediately(pickerModel.currentPlaylistToExport, ::updatePlaylistToExport) + collectImmediately(pickerModel.currentExportConfig, ::updateExportConfig) + } + + override fun onStart() { + super.onStart() + (requireDialog() as AlertDialog) + .getButton(AlertDialog.BUTTON_POSITIVE) + .setOnClickListener { _ -> + val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPlaylistToExport.value) + + val fileName = + pendingPlaylist.name + .resolve(requireContext()) + .replace(SAFE_FILE_NAME_REGEX, "_") + ".m3u" + + requireNotNull(createDocumentLauncher) { + "Create document launcher was not available" + } + .launch(fileName) + } + } + + private fun updatePlaylistToExport(playlist: Playlist?) { + if (playlist == null) { + logD("No playlist to export, leaving") + findNavController().navigateUp() + return + } + } + + private fun updateExportConfig(config: ExportConfig) { + val binding = requireBinding() + binding.exportPathsGroup.check( + if (config.absolute) { + R.id.export_absolute_paths + } else { + R.id.export_relative_paths + }) + logD(config.windowsPaths) + binding.exportWindowsPaths.isChecked = config.windowsPaths + } + + private companion object { + val SAFE_FILE_NAME_REGEX = Regex("[^a-zA-Z0-9.-]") + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt index b21d9e991..356948553 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt @@ -31,6 +31,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.external.ExportConfig import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW @@ -53,6 +54,16 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M val currentPlaylistToRename: StateFlow get() = _currentPlaylistToRename + private val _currentPlaylistToExport = MutableStateFlow(null) + /** An existing [Playlist] that is being exported. Null if none yet. */ + val currentPlaylistToExport: StateFlow + get() = _currentPlaylistToExport + + private val _currentExportConfig = MutableStateFlow(DEFAULT_EXPORT_CONFIG) + /** The current [ExportConfig] to use when exporting a playlist. */ + val currentExportConfig: StateFlow + get() = _currentExportConfig + private val _currentPlaylistToDelete = MutableStateFlow(null) /** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */ val currentPlaylistToDelete: StateFlow @@ -110,6 +121,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } logD("Updated chosen name to $chosenName") refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value + + // TODO: Add music syncing for other playlist states here + + _currentPlaylistToExport.value = + _currentPlaylistToExport.value?.let { playlist -> + musicRepository.userLibrary?.findPlaylist(playlist.uid) + } + logD("Updated playlist to export to ${_currentPlaylistToExport.value}") } refreshChoicesWith?.let(::refreshPlaylistChoices) @@ -169,6 +188,33 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } } + /** + * Set a new [currentPlaylisttoExport] from a [Playlist] [Music.UID]. + * + * @param playlistUid The [Music.UID] of the [Playlist] to export. + */ + fun setPlaylistToExport(playlistUid: Music.UID) { + logD("Opening playlist $playlistUid to export") + // TODO: Add this guard to the rest of the methods here + if (_currentPlaylistToExport.value?.uid == playlistUid) return + _currentPlaylistToExport.value = musicRepository.userLibrary?.findPlaylist(playlistUid) + if (_currentPlaylistToExport.value == null) { + logW("Given playlist UID to export was invalid") + } else { + _currentExportConfig.value = DEFAULT_EXPORT_CONFIG + } + } + + /** + * Update [currentExportConfig] based on new user input. + * + * @param exportConfig The new [ExportConfig] to use. + */ + fun setExportConfig(exportConfig: ExportConfig) { + logD("Setting export config to $exportConfig") + _currentExportConfig.value = exportConfig + } + /** * Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID]. * @@ -238,6 +284,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M PlaylistChoice(it, songs.all(songSet::contains)) } } + + private companion object { + private val DEFAULT_EXPORT_CONFIG = ExportConfig(absolute = false, windowsPaths = false) + } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt index e2f56a21c..af29c3620 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt @@ -26,7 +26,10 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface ExternalModule { - @Binds fun playlistImporter(playlistImporter: ExternalPlaylistManagerImpl): ExternalPlaylistManager + @Binds + fun externalPlaylistManager( + externalPlaylistManager: ExternalPlaylistManagerImpl + ): ExternalPlaylistManager @Binds fun m3u(m3u: M3UImpl): M3U } 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 da74d66a2..c115a156b 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -295,6 +295,10 @@ class SearchFragment : ListFragment() { logD("Deleting ${decision.playlist}") SearchFragmentDirections.deletePlaylist(decision.playlist.uid) } + is PlaylistDecision.Export -> { + logD("Exporting ${decision.playlist}") + SearchFragmentDirections.exportPlaylist(decision.playlist.uid) + } is PlaylistDecision.Add -> { logD("Adding ${decision.songs.size} to a playlist") SearchFragmentDirections.addToPlaylist( diff --git a/app/src/main/res/drawable/ic_import_24.xml b/app/src/main/res/drawable/ic_import_24.xml index f63386171..427745f84 100644 --- a/app/src/main/res/drawable/ic_import_24.xml +++ b/app/src/main/res/drawable/ic_import_24.xml @@ -7,5 +7,5 @@ android:tint="?attr/colorControlNormal"> + android:pathData="M440,760L520,760L520,593L584,657L640,600L480,440L320,600L377,656L440,593L440,760ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z"/> diff --git a/app/src/main/res/layout/dialog_playlist_export.xml b/app/src/main/res/layout/dialog_playlist_export.xml index cdc89f25a..15f12f8bb 100644 --- a/app/src/main/res/layout/dialog_playlist_export.xml +++ b/app/src/main/res/layout/dialog_playlist_export.xml @@ -1,6 +1,78 @@ - + - \ No newline at end of file + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/playlist.xml b/app/src/main/res/menu/playlist.xml index af3277d8a..a5cdca330 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:title="@string/lbl_rename" android:icon="@drawable/ic_edit_24"/> + + @@ -180,6 +183,9 @@ + @@ -376,6 +382,9 @@ + @@ -416,6 +425,16 @@ app:argType="org.oxycblt.auxio.music.Music$UID" /> + + + + New playlist Empty playlist Imported playlist + Export + Export playlist Rename Rename playlist Delete @@ -157,6 +159,12 @@ Add + Path style + Absolute + Relative + + Use Windows-compatible paths + State saved @@ -310,7 +318,8 @@ No music found Music loading failed Auxio needs permission to read your music library - Could not import a playlist from this file + Unable to import a playlist from this file + Unable to export the playlist to this file No app found that can handle this task No folders