music: implement exporting frontend

Implement the exporting dialog and flow in all places in the app.
This commit is contained in:
Alexander Capehart 2023-12-23 12:15:35 -07:00
parent 68e4da5e7e
commit 3f1f2f5c2d
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
16 changed files with 421 additions and 21 deletions

View file

@ -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)
}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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<String>? = null
private var filePickerLauncher: ActivityResultLauncher<String>? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var createDocumentLauncher: ActivityResultLauncher<String>? = 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<Song>, isFastScrolling: Boolean) {

View file

@ -288,6 +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_share)
} else {
setOf()
@ -320,6 +321,7 @@ 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_delete -> musicModel.deletePlaylist(menu.playlist)
R.id.action_share -> requireContext().share(menu.playlist)
else -> error("Unexpected menu item $item")

View file

@ -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<PlaylistDecision>
get() = _playlistDecision
private val _playlistError = MutableEvent<PlaylistError>()
val playlistError: Event<PlaylistError>
get() = _playlistError
private val _importError = MutableEvent<Unit>()
/** Flag for when playlist importing failed. Consume this and show an error if active. */
val importError: Event<Unit>
get() = _importError
private val _exportError = MutableEvent<Unit>()
/** Flag for when playlist exporting failed. Consume this and show an error if active. */
val exportError: Event<Unit>
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<Song>) : PlaylistDecision
}
sealed interface PlaylistError {
data object ImportFailed : PlaylistError
data object ExportFailed : PlaylistError
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.decision
class ExportPlaylistDialog {
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<DialogPlaylistExportBinding>() {
private val musicModel: MusicViewModel by activityViewModels()
private val pickerModel: PlaylistPickerViewModel by viewModels()
private var createDocumentLauncher: ActivityResultLauncher<String>? = 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.-]")
}
}

View file

@ -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<Playlist?>
get() = _currentPlaylistToRename
private val _currentPlaylistToExport = MutableStateFlow<Playlist?>(null)
/** An existing [Playlist] that is being exported. Null if none yet. */
val currentPlaylistToExport: StateFlow<Playlist?>
get() = _currentPlaylistToExport
private val _currentExportConfig = MutableStateFlow(DEFAULT_EXPORT_CONFIG)
/** The current [ExportConfig] to use when exporting a playlist. */
val currentExportConfig: StateFlow<ExportConfig>
get() = _currentExportConfig
private val _currentPlaylistToDelete = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */
val currentPlaylistToDelete: StateFlow<Playlist?>
@ -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)
}
}
/**

View file

@ -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
}

View file

@ -295,6 +295,10 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
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(

View file

@ -7,5 +7,5 @@
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M240,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"/>
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"/>
</vector>

View file

@ -1,6 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/export_paths_header"
style="@style/Widget.Auxio.TextView.Header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/spacing_large"
android:paddingEnd="@dimen/spacing_large"
android:text="@string/lbl_path_style"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/export_paths_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_large"
android:layout_marginTop="@dimen/spacing_tiny"
android:layout_marginEnd="@dimen/spacing_large"
android:gravity="center"
app:checkedButton="@+id/export_relative_paths"
app:layout_constraintTop_toBottomOf="@+id/export_paths_header"
app:selectionRequired="true"
app:singleSelection="true">
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/export_relative_paths"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/lbl_path_style_relative"
tools:icon="@drawable/ic_check_24" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/export_absolute_paths"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/lbl_path_style_absolute"/>
</com.google.android.material.button.MaterialButtonToggleGroup>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:orientation="horizontal"
android:paddingTop="@dimen/spacing_tiny"
android:paddingBottom="@dimen/spacing_tiny">
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/export_windows_paths"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_mid_medium"
android:clickable="false"
android:focusable="false"
android:paddingStart="@dimen/spacing_medium"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
tools:ignore="RtlSymmetry,contentDescription"
android:text="@string/lbl_windows_paths" />
</FrameLayout>
</LinearLayout>

View file

@ -24,6 +24,10 @@
android:id="@+id/action_rename"
android:title="@string/lbl_rename"
android:icon="@drawable/ic_edit_24"/>
<item
android:id="@+id/action_playlist_export"
android:title="@string/lbl_export"
android:icon="@drawable/ic_save_24"/>
<item
android:id="@+id/action_delete"
android:title="@string/lbl_delete"

View file

@ -66,6 +66,9 @@
<action
android:id="@+id/rename_playlist"
app:destination="@id/rename_playlist_dialog" />
<action
android:id="@+id/export_playlist"
app:destination="@id/export_playlist_dialog" />
<action
android:id="@+id/delete_playlist"
app:destination="@id/delete_playlist_dialog" />
@ -180,6 +183,9 @@
<action
android:id="@+id/delete_playlist"
app:destination="@id/delete_playlist_dialog" />
<action
android:id="@+id/export_playlist"
app:destination="@id/export_playlist_dialog" />
<action
android:id="@+id/add_to_playlist"
app:destination="@id/add_to_playlist_dialog" />
@ -376,6 +382,9 @@
<action
android:id="@+id/rename_playlist"
app:destination="@id/rename_playlist_dialog" />
<action
android:id="@+id/export_playlist"
app:destination="@id/export_playlist_dialog" />
<action
android:id="@+id/delete_playlist"
app:destination="@id/delete_playlist_dialog" />
@ -416,6 +425,16 @@
app:argType="org.oxycblt.auxio.music.Music$UID" />
</dialog>
<dialog
android:id="@+id/export_playlist_dialog"
android:name="org.oxycblt.auxio.music.decision.ExportPlaylistDialog"
android:label="rename_playlist_dialog"
tools:layout="@layout/dialog_playlist_name">
<argument
android:name="playlistUid"
app:argType="org.oxycblt.auxio.music.Music$UID" />
</dialog>
<dialog
android:id="@+id/delete_playlist_dialog"
android:name="org.oxycblt.auxio.music.decision.DeletePlaylistDialog"

View file

@ -90,6 +90,8 @@
<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_export">Export</string>
<string name="lbl_export_playlist">Export playlist</string>
<string name="lbl_rename">Rename</string>
<string name="lbl_rename_playlist">Rename playlist</string>
<string name="lbl_delete">Delete</string>
@ -157,6 +159,12 @@
<!-- As in to add a new folder in the "Music folders" setting -->
<string name="lbl_add">Add</string>
<string name="lbl_path_style">Path style</string>
<string name="lbl_path_style_absolute">Absolute</string>
<string name="lbl_path_style_relative">Relative</string>
<string name="lbl_windows_paths">Use Windows-compatible paths</string>
<!-- Referring to playback state -->
<string name="lbl_state_saved">State saved</string>
<!-- Referring to playback state -->
@ -310,7 +318,8 @@
<string name="err_no_music">No music found</string>
<string name="err_index_failed">Music loading failed</string>
<string name="err_no_perms">Auxio needs permission to read your music library</string>
<string name="err_import_failed">Could not import a playlist from this file</string>
<string name="err_import_failed">Unable to import a playlist from this file</string>
<string name="err_export_failed">Unable to export the playlist to this file</string>
<string name="err_no_app">No app found that can handle this task</string>
<!-- No folders in the "Music Folders" setting -->
<string name="err_no_dirs">No folders</string>