music: implement exporting frontend
Implement the exporting dialog and flow in all places in the app.
This commit is contained in:
parent
68e4da5e7e
commit
3f1f2f5c2d
16 changed files with 421 additions and 21 deletions
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.-]")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue