home: add playlist import flow
Connect the playlist importing system to the home view's playlist add button.
This commit is contained in:
parent
2195431c66
commit
634ff0d823
3 changed files with 64 additions and 144 deletions
|
@ -39,6 +39,8 @@ import com.google.android.material.appbar.AppBarLayout
|
|||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||
import com.leinardi.android.speeddial.SpeedDialView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
|
@ -77,6 +79,7 @@ import org.oxycblt.auxio.util.lazyReflectedMethod
|
|||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
|
||||
|
@ -86,13 +89,16 @@ import org.oxycblt.auxio.util.navigateSafe
|
|||
*/
|
||||
@AndroidEntryPoint
|
||||
class HomeFragment :
|
||||
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
|
||||
SelectionFragment<FragmentHomeBinding>(),
|
||||
AppBarLayout.OnOffsetChangedListener,
|
||||
SpeedDialView.OnActionSelectedListener {
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
||||
private var filePickerLauncher: ActivityResultLauncher<String>? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -121,6 +127,17 @@ class HomeFragment :
|
|||
musicModel.refresh()
|
||||
}
|
||||
|
||||
filePickerLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
logW("No URI returned from file picker")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
logD("Received playlist URI $uri")
|
||||
musicModel.importPlaylist(uri)
|
||||
}
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||
binding.homeNormalToolbar.apply {
|
||||
|
@ -176,20 +193,7 @@ class HomeFragment :
|
|||
|
||||
binding.homeNewPlaylistFab.apply {
|
||||
inflate(R.menu.new_playlist_actions)
|
||||
setOnActionSelectedListener { action ->
|
||||
when (action.id) {
|
||||
R.id.action_new_playlist -> {
|
||||
logD("Creating playlist")
|
||||
musicModel.createPlaylist()
|
||||
}
|
||||
R.id.action_import_playlist -> {
|
||||
TODO("Not implemented")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
close()
|
||||
true
|
||||
}
|
||||
setOnActionSelectedListener(this@HomeFragment)
|
||||
}
|
||||
|
||||
hideAllFabs()
|
||||
|
@ -206,6 +210,7 @@ class HomeFragment :
|
|||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collectImmediately(musicModel.importError.flow, ::handleImportError)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
}
|
||||
|
||||
|
@ -223,6 +228,7 @@ class HomeFragment :
|
|||
storagePermissionLauncher = null
|
||||
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
||||
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
|
||||
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
|
||||
}
|
||||
|
||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||
|
@ -281,6 +287,24 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
|
||||
when (actionItem.id) {
|
||||
R.id.action_new_playlist -> {
|
||||
logD("Creating playlist")
|
||||
musicModel.createPlaylist()
|
||||
}
|
||||
R.id.action_import_playlist -> {
|
||||
logD("Importing playlist")
|
||||
filePickerLauncher?.launch("audio/x-mpegurl")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
// Returning false to close th speed dial results in no animation, manually close instead.
|
||||
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||
requireBinding().homeNewPlaylistFab.close()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setupPager(binding: FragmentHomeBinding) {
|
||||
binding.homePager.adapter =
|
||||
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
|
||||
|
@ -464,6 +488,13 @@ class HomeFragment :
|
|||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun handleImportError(flag: Unit?) {
|
||||
if (flag != null) {
|
||||
requireContext().showToast(R.string.err_import_failed)
|
||||
musicModel.importError.consume()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
||||
updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value)
|
||||
}
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ContentPathResolver.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.fs
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
/**
|
||||
* Resolves a content URI into a [Path] instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Integrate this with [MediaStoreExtractor].
|
||||
*/
|
||||
interface ContentPathResolver {
|
||||
/**
|
||||
* Resolve a content [Uri] into it's corresponding [Path].
|
||||
*
|
||||
* @param uri The content [Uri] to resolve.
|
||||
* @return The corresponding [Path], or null if the [Uri] is invalid.
|
||||
*/
|
||||
fun resolve(uri: Uri): Path?
|
||||
|
||||
companion object {
|
||||
fun from(context: Context, volumeManager: VolumeManager) =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
|
||||
Api29ContentPathResolverImpl(context.contentResolverSafe, volumeManager)
|
||||
else -> Api21ContentPathResolverImpl(context.contentResolverSafe, volumeManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Api21ContentPathResolverImpl(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val volumeManager: VolumeManager
|
||||
) : ContentPathResolver {
|
||||
override fun resolve(uri: Uri): Path? {
|
||||
val rawPath =
|
||||
contentResolver.useQuery(uri, arrayOf(MediaStore.MediaColumns.DATA)) { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA))
|
||||
}
|
||||
|
||||
if (rawPath == null) {
|
||||
logE("No data available for uri $uri")
|
||||
return null
|
||||
}
|
||||
|
||||
val volumes = volumeManager.getVolumes()
|
||||
for (volume in volumes) {
|
||||
val volumePath = (volume.components ?: continue).toString()
|
||||
val strippedPath = rawPath.removePrefix(volumePath)
|
||||
if (strippedPath != rawPath) {
|
||||
return Path(volume, Components.parse(strippedPath))
|
||||
}
|
||||
}
|
||||
|
||||
logE("No volume found for uri $uri")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private class Api29ContentPathResolverImpl(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val volumeManager: VolumeManager
|
||||
) : ContentPathResolver {
|
||||
private data class RawPath(val volumeName: String?, val relativePath: String?)
|
||||
|
||||
override fun resolve(uri: Uri): Path? {
|
||||
val rawPath =
|
||||
contentResolver.useQuery(
|
||||
uri,
|
||||
arrayOf(
|
||||
MediaStore.MediaColumns.VOLUME_NAME, MediaStore.MediaColumns.RELATIVE_PATH)) {
|
||||
cursor ->
|
||||
cursor.moveToFirst()
|
||||
RawPath(
|
||||
cursor.getStringOrNull(
|
||||
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.VOLUME_NAME)),
|
||||
cursor.getStringOrNull(
|
||||
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH)))
|
||||
}
|
||||
|
||||
if (rawPath.volumeName == null || rawPath.relativePath == null) {
|
||||
logE("No data available for uri $uri (raw path obtained: $rawPath)")
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the StorageVolume whose MediaStore name corresponds to this song.
|
||||
// This is combined with the plain relative path column to create the directory.
|
||||
val volume = volumeManager.getVolumes().find { it.mediaStoreName == rawPath.volumeName }
|
||||
if (volume != null) {
|
||||
return Path(volume, Components.parse(rawPath.relativePath))
|
||||
}
|
||||
|
||||
logE("No volume found for uri $uri")
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* DocumentTreePathFactory.kt is part of Auxio.
|
||||
* DocumentPathFactory.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
|
||||
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.dirs
|
||||
package org.oxycblt.auxio.music.fs
|
||||
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
|
@ -35,7 +35,15 @@ import org.oxycblt.auxio.music.fs.VolumeManager
|
|||
*/
|
||||
interface DocumentTreePathFactory {
|
||||
/**
|
||||
* Unpacks a document tree URI into a [Path] instance, using [deserializeDocumentTreePath].
|
||||
* Unpacks a document URI into a [Path] instance, using [fromDocumentId].
|
||||
*
|
||||
* @param uri The document URI to unpack.
|
||||
* @return The [Path] instance, or null if the URI could not be unpacked.
|
||||
*/
|
||||
fun unpackDocumentUri(uri: Uri): Path?
|
||||
|
||||
/**
|
||||
* Unpacks a document tree URI into a [Path] instance, using [fromDocumentId].
|
||||
*
|
||||
* @param uri The document tree URI to unpack.
|
||||
* @return The [Path] instance, or null if the URI could not be unpacked.
|
||||
|
@ -48,7 +56,7 @@ interface DocumentTreePathFactory {
|
|||
* @param path The [Path] instance to serialize.
|
||||
* @return The serialized path.
|
||||
*/
|
||||
fun serializeDocumentTreePath(path: Path): String
|
||||
fun toDocumentId(path: Path): String
|
||||
|
||||
/**
|
||||
* Deserializes a document tree URI format path into a [Path] instance.
|
||||
|
@ -56,11 +64,13 @@ interface DocumentTreePathFactory {
|
|||
* @param path The path to deserialize.
|
||||
* @return The [Path] instance, or null if the path could not be deserialized.
|
||||
*/
|
||||
fun deserializeDocumentTreePath(path: String): Path?
|
||||
fun fromDocumentId(path: String): Path?
|
||||
}
|
||||
|
||||
class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
|
||||
DocumentTreePathFactory {
|
||||
override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri))
|
||||
|
||||
override fun unpackDocumentTreeUri(uri: Uri): Path? {
|
||||
// Convert the document tree URI into it's relative path form, which can then be
|
||||
// parsed into a Directory instance.
|
||||
|
@ -68,10 +78,10 @@ class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager:
|
|||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
uri, DocumentsContract.getTreeDocumentId(uri))
|
||||
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
|
||||
return deserializeDocumentTreePath(treeUri)
|
||||
return fromDocumentId(treeUri)
|
||||
}
|
||||
|
||||
override fun serializeDocumentTreePath(path: Path): String =
|
||||
override fun toDocumentId(path: Path): String =
|
||||
when (val volume = path.volume) {
|
||||
// The primary storage has a volume prefix of "primary", regardless
|
||||
// of if it's internal or not.
|
||||
|
@ -80,7 +90,7 @@ class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager:
|
|||
is Volume.External -> "${volume.id}:${path.components}"
|
||||
}
|
||||
|
||||
override fun deserializeDocumentTreePath(path: String): Path? {
|
||||
override fun fromDocumentId(path: String): Path? {
|
||||
// Document tree URIs consist of a prefixed volume name followed by a relative path,
|
||||
// delimited with a colon.
|
||||
val split = path.split(File.pathSeparator, limit = 2)
|
Loading…
Reference in a new issue