home: add playlist import flow

Connect the playlist importing system to the home view's playlist add
button.
This commit is contained in:
Alexander Capehart 2023-12-20 12:13:19 -07:00
parent 2195431c66
commit 634ff0d823
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
3 changed files with 64 additions and 144 deletions

View file

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

View file

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

View file

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