music: more m3u support
- Turns out path extraction via MediaStore doesn't work, have to grok the URI format. - Added playlist name extraction - Proactively handling whitespace
This commit is contained in:
parent
634ff0d823
commit
c66a9b19b5
9 changed files with 102 additions and 46 deletions
|
@ -24,8 +24,8 @@ import androidx.core.content.edit
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.dirs.DocumentTreePathFactory
|
||||
import org.oxycblt.auxio.music.dirs.MusicDirectories
|
||||
import org.oxycblt.auxio.music.fs.DocumentPathFactory
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
@ -57,10 +57,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
|
||||
class MusicSettingsImpl
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
val documentTreePathFactory: DocumentTreePathFactory
|
||||
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
||||
constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) :
|
||||
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
||||
private val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||
|
||||
override var musicDirs: MusicDirectories
|
||||
|
@ -68,7 +66,7 @@ constructor(
|
|||
val dirs =
|
||||
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
|
||||
?: emptySet())
|
||||
.mapNotNull(documentTreePathFactory::deserializeDocumentTreePath)
|
||||
.mapNotNull(documentPathFactory::fromDocumentId)
|
||||
return MusicDirectories(
|
||||
dirs,
|
||||
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
|
||||
|
@ -77,7 +75,7 @@ constructor(
|
|||
sharedPreferences.edit {
|
||||
putStringSet(
|
||||
getString(R.string.set_key_music_dirs),
|
||||
value.dirs.map(documentTreePathFactory::serializeDocumentTreePath).toSet())
|
||||
value.dirs.map(documentPathFactory::toDocumentId).toSet())
|
||||
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
|
||||
apply()
|
||||
}
|
||||
|
|
|
@ -141,6 +141,11 @@ constructor(
|
|||
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
||||
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
|
||||
|
||||
if (songs.isEmpty()) {
|
||||
_importError.put(Unit)
|
||||
return@launch
|
||||
}
|
||||
|
||||
createPlaylist(importedPlaylist.name, songs)
|
||||
}
|
||||
|
||||
|
|
|
@ -18,14 +18,8 @@
|
|||
|
||||
package org.oxycblt.auxio.music.dirs
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DirectoryModule {
|
||||
@Binds
|
||||
fun documentTreePathFactory(factory: DocumentTreePathFactoryImpl): DocumentTreePathFactory
|
||||
}
|
||||
@Module @InstallIn(SingletonComponent::class) interface DirectoryModule {}
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.BuildConfig
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.fs.DocumentPathFactory
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
@ -48,7 +49,7 @@ class MusicDirsDialog :
|
|||
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
||||
private val dirAdapter = DirectoryAdapter(this)
|
||||
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
|
||||
@Inject lateinit var documentTreePathFactory: DocumentTreePathFactory
|
||||
@Inject lateinit var documentPathFactory: DocumentPathFactory
|
||||
@Inject lateinit var musicSettings: MusicSettings
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
|
@ -101,8 +102,7 @@ class MusicDirsDialog :
|
|||
if (pendingDirs != null) {
|
||||
dirs =
|
||||
MusicDirectories(
|
||||
pendingDirs.mapNotNull(
|
||||
documentTreePathFactory::deserializeDocumentTreePath),
|
||||
pendingDirs.mapNotNull(documentPathFactory::fromDocumentId),
|
||||
savedInstanceState.getBoolean(KEY_PENDING_MODE))
|
||||
}
|
||||
}
|
||||
|
@ -126,8 +126,7 @@ class MusicDirsDialog :
|
|||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putStringArrayList(
|
||||
KEY_PENDING_DIRS,
|
||||
ArrayList(dirAdapter.dirs.map(documentTreePathFactory::serializeDocumentTreePath)))
|
||||
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId)))
|
||||
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
|
||||
}
|
||||
|
||||
|
@ -155,7 +154,7 @@ class MusicDirsDialog :
|
|||
return
|
||||
}
|
||||
|
||||
val dir = documentTreePathFactory.unpackDocumentTreeUri(uri)
|
||||
val dir = documentPathFactory.unpackDocumentTreeUri(uri)
|
||||
|
||||
if (dir != null) {
|
||||
dirAdapter.add(dir)
|
||||
|
|
|
@ -24,39 +24,81 @@ import java.io.InputStreamReader
|
|||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.fs.Components
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* Minimal M3U file format implementation.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface M3U {
|
||||
fun read(stream: InputStream, workingDirectory: Path): List<Path>?
|
||||
/**
|
||||
* Reads an M3U file from the given [stream] and returns a [ImportedPlaylist] containing the
|
||||
* paths to the files listed in the M3U file.
|
||||
*
|
||||
* @param stream The stream to read the M3U file from.
|
||||
* @param workingDirectory The directory that the M3U file is contained in. This is used to
|
||||
* resolve relative paths.
|
||||
* @return An [ImportedPlaylist] containing the paths to the files listed in the M3U file,
|
||||
*/
|
||||
fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist?
|
||||
}
|
||||
|
||||
class M3UImpl @Inject constructor() : M3U {
|
||||
override fun read(stream: InputStream, workingDirectory: Path): List<Path>? {
|
||||
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
|
||||
val reader = BufferedReader(InputStreamReader(stream))
|
||||
val media = mutableListOf<Path>()
|
||||
val paths = mutableListOf<Path>()
|
||||
var name: String? = null
|
||||
|
||||
consumeFile@ while (true) {
|
||||
var path: String?
|
||||
collectMetadata@ while (true) {
|
||||
val line = reader.readLine() ?: break@consumeFile
|
||||
if (!line.startsWith("#")) {
|
||||
// The M3U format consists of "entries" that begin with a bunch of metadata
|
||||
// prefixed with "#", and then a relative/absolute path or url to the file.
|
||||
// We don't really care about the metadata except for the playlist name, so
|
||||
// we discard everything but that.
|
||||
val currentLine =
|
||||
(reader.readLine() ?: break@consumeFile).correctWhitespace()
|
||||
?: continue@collectMetadata
|
||||
if (currentLine.startsWith("#")) {
|
||||
// Metadata entries are roughly structured
|
||||
val split = currentLine.split(":", limit = 2)
|
||||
when (split[0]) {
|
||||
// Playlist name
|
||||
"#PLAYLIST" -> name = split.getOrNull(1)?.correctWhitespace()
|
||||
// Add more metadata handling here if needed.
|
||||
else -> {}
|
||||
}
|
||||
} else {
|
||||
// Something that isn't a metadata entry, assume it's a path. It could be
|
||||
// a URL, but it'll just get mangled really badly and not match with anything,
|
||||
// so it's okay.
|
||||
path = currentLine
|
||||
break@collectMetadata
|
||||
}
|
||||
}
|
||||
|
||||
val path = reader.readLine()
|
||||
if (path == null) {
|
||||
logW("Expected a path, instead got an EOF")
|
||||
break@consumeFile
|
||||
}
|
||||
|
||||
// The path may be relative to the directory that the M3U file is contained in,
|
||||
// so we may need to resolve it into an absolute path before moving ahead.
|
||||
val relativeComponents = Components.parse(path)
|
||||
val absoluteComponents =
|
||||
resolveRelativePath(relativeComponents, workingDirectory.components)
|
||||
|
||||
media.add(Path(workingDirectory.volume, absoluteComponents))
|
||||
paths.add(Path(workingDirectory.volume, absoluteComponents))
|
||||
}
|
||||
|
||||
return media.ifEmpty { null }
|
||||
return if (paths.isNotEmpty()) {
|
||||
ImportedPlaylist(name, paths)
|
||||
} else {
|
||||
// Couldn't get anything useful out of this file.
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveRelativePath(
|
||||
|
@ -66,8 +108,12 @@ class M3UImpl @Inject constructor() : M3U {
|
|||
var components = workingDirectory
|
||||
for (component in relative.components) {
|
||||
when (component) {
|
||||
// Parent specifier, go "back" one directory (in practice cleave off the last
|
||||
// component)
|
||||
".." -> components = components.parent()
|
||||
// Current directory, the components are already there.
|
||||
"." -> {}
|
||||
// New directory, add it
|
||||
else -> components = components.child(component)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,28 +22,42 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.fs.ContentPathResolver
|
||||
import org.oxycblt.auxio.music.fs.DocumentPathFactory
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||
|
||||
/**
|
||||
* Generic playlist file importing abstraction.
|
||||
*
|
||||
* @see ImportedPlaylist
|
||||
* @see M3U
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface PlaylistImporter {
|
||||
suspend fun import(uri: Uri): ImportedPlaylist?
|
||||
}
|
||||
|
||||
/**
|
||||
* A playlist that has been imported.
|
||||
*
|
||||
* @property name The name of the playlist. May be null if not provided.
|
||||
* @property paths The paths of the files in the playlist.
|
||||
* @see PlaylistImporter
|
||||
* @see M3U
|
||||
*/
|
||||
data class ImportedPlaylist(val name: String?, val paths: List<Path>)
|
||||
|
||||
class PlaylistImporterImpl
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val contentPathResolver: ContentPathResolver,
|
||||
private val documentPathFactory: DocumentPathFactory,
|
||||
private val m3u: M3U
|
||||
) : PlaylistImporter {
|
||||
override suspend fun import(uri: Uri): ImportedPlaylist? {
|
||||
val workingDirectory = contentPathResolver.resolve(uri) ?: return null
|
||||
val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return null
|
||||
return context.contentResolverSafe.openInputStream(uri)?.use {
|
||||
val paths = m3u.read(it, workingDirectory) ?: return null
|
||||
return ImportedPlaylist(null, paths)
|
||||
return m3u.read(it, filePath.directory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,18 +22,13 @@ import android.net.Uri
|
|||
import android.provider.DocumentsContract
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.fs.Components
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
import org.oxycblt.auxio.music.fs.Volume
|
||||
import org.oxycblt.auxio.music.fs.VolumeManager
|
||||
|
||||
/**
|
||||
* A factory for parsing the reverse-engineered format of the URIs obtained from the document tree
|
||||
* (i.e directory) folder.
|
||||
* A factory for parsing the reverse-engineered format of the URIs obtained from document picker.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface DocumentTreePathFactory {
|
||||
interface DocumentPathFactory {
|
||||
/**
|
||||
* Unpacks a document URI into a [Path] instance, using [fromDocumentId].
|
||||
*
|
||||
|
@ -67,8 +62,8 @@ interface DocumentTreePathFactory {
|
|||
fun fromDocumentId(path: String): Path?
|
||||
}
|
||||
|
||||
class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
|
||||
DocumentTreePathFactory {
|
||||
class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
|
||||
DocumentPathFactory {
|
||||
override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri))
|
||||
|
||||
override fun unpackDocumentTreeUri(uri: Uri): Path? {
|
||||
|
|
|
@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.fs
|
|||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.storage.StorageManager
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
@ -39,11 +40,14 @@ class FsModule {
|
|||
fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) =
|
||||
MediaStoreExtractor.from(context, volumeManager)
|
||||
|
||||
@Provides
|
||||
fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) =
|
||||
ContentPathResolver.from(context, volumeManager)
|
||||
|
||||
@Provides
|
||||
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
|
||||
context.contentResolverSafe
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface FsBindsModule {
|
||||
@Binds
|
||||
fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory
|
||||
}
|
||||
|
|
|
@ -310,6 +310,7 @@
|
|||
<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_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