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.floatingactionbutton.FloatingActionButton
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
import java.lang.reflect.Method
|
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.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
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
|
* 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
|
@AndroidEntryPoint
|
||||||
class HomeFragment :
|
class HomeFragment :
|
||||||
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
|
SelectionFragment<FragmentHomeBinding>(),
|
||||||
|
AppBarLayout.OnOffsetChangedListener,
|
||||||
|
SpeedDialView.OnActionSelectedListener {
|
||||||
override val listModel: ListViewModel by activityViewModels()
|
override val listModel: ListViewModel by activityViewModels()
|
||||||
override val musicModel: MusicViewModel by activityViewModels()
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
||||||
|
private var filePickerLauncher: ActivityResultLauncher<String>? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -121,6 +127,17 @@ class HomeFragment :
|
||||||
musicModel.refresh()
|
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 ---
|
// --- UI SETUP ---
|
||||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||||
binding.homeNormalToolbar.apply {
|
binding.homeNormalToolbar.apply {
|
||||||
|
|
@ -176,20 +193,7 @@ class HomeFragment :
|
||||||
|
|
||||||
binding.homeNewPlaylistFab.apply {
|
binding.homeNewPlaylistFab.apply {
|
||||||
inflate(R.menu.new_playlist_actions)
|
inflate(R.menu.new_playlist_actions)
|
||||||
setOnActionSelectedListener { action ->
|
setOnActionSelectedListener(this@HomeFragment)
|
||||||
when (action.id) {
|
|
||||||
R.id.action_new_playlist -> {
|
|
||||||
logD("Creating playlist")
|
|
||||||
musicModel.createPlaylist()
|
|
||||||
}
|
|
||||||
R.id.action_import_playlist -> {
|
|
||||||
TODO("Not implemented")
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
close()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hideAllFabs()
|
hideAllFabs()
|
||||||
|
|
@ -206,6 +210,7 @@ class HomeFragment :
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||||
|
collectImmediately(musicModel.importError.flow, ::handleImportError)
|
||||||
collect(detailModel.toShow.flow, ::handleShow)
|
collect(detailModel.toShow.flow, ::handleShow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,6 +228,7 @@ class HomeFragment :
|
||||||
storagePermissionLauncher = null
|
storagePermissionLauncher = null
|
||||||
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
||||||
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
|
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
|
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
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) {
|
private fun setupPager(binding: FragmentHomeBinding) {
|
||||||
binding.homePager.adapter =
|
binding.homePager.adapter =
|
||||||
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
|
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
|
||||||
|
|
@ -464,6 +488,13 @@ class HomeFragment :
|
||||||
findNavController().navigateSafe(directions)
|
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) {
|
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
||||||
updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value)
|
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
|
* 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
|
* 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
|
* 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/>.
|
* 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.net.Uri
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
|
|
@ -35,7 +35,15 @@ import org.oxycblt.auxio.music.fs.VolumeManager
|
||||||
*/
|
*/
|
||||||
interface DocumentTreePathFactory {
|
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.
|
* @param uri The document tree URI to unpack.
|
||||||
* @return The [Path] instance, or null if the URI could not be unpacked.
|
* @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.
|
* @param path The [Path] instance to serialize.
|
||||||
* @return The serialized path.
|
* @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.
|
* Deserializes a document tree URI format path into a [Path] instance.
|
||||||
|
|
@ -56,11 +64,13 @@ interface DocumentTreePathFactory {
|
||||||
* @param path The path to deserialize.
|
* @param path The path to deserialize.
|
||||||
* @return The [Path] instance, or null if the path could not be deserialized.
|
* @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) :
|
class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
|
||||||
DocumentTreePathFactory {
|
DocumentTreePathFactory {
|
||||||
|
override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri))
|
||||||
|
|
||||||
override fun unpackDocumentTreeUri(uri: Uri): Path? {
|
override fun unpackDocumentTreeUri(uri: Uri): Path? {
|
||||||
// Convert the document tree URI into it's relative path form, which can then be
|
// Convert the document tree URI into it's relative path form, which can then be
|
||||||
// parsed into a Directory instance.
|
// parsed into a Directory instance.
|
||||||
|
|
@ -68,10 +78,10 @@ class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager:
|
||||||
DocumentsContract.buildDocumentUriUsingTree(
|
DocumentsContract.buildDocumentUriUsingTree(
|
||||||
uri, DocumentsContract.getTreeDocumentId(uri))
|
uri, DocumentsContract.getTreeDocumentId(uri))
|
||||||
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
|
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) {
|
when (val volume = path.volume) {
|
||||||
// The primary storage has a volume prefix of "primary", regardless
|
// The primary storage has a volume prefix of "primary", regardless
|
||||||
// of if it's internal or not.
|
// 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}"
|
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,
|
// Document tree URIs consist of a prefixed volume name followed by a relative path,
|
||||||
// delimited with a colon.
|
// delimited with a colon.
|
||||||
val split = path.split(File.pathSeparator, limit = 2)
|
val split = path.split(File.pathSeparator, limit = 2)
|
||||||
Loading…
Reference in a new issue