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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.dirs.DocumentTreePathFactory
|
|
||||||
import org.oxycblt.auxio.music.dirs.MusicDirectories
|
import org.oxycblt.auxio.music.dirs.MusicDirectories
|
||||||
|
import org.oxycblt.auxio.music.fs.DocumentPathFactory
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -57,10 +57,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
|
|
||||||
class MusicSettingsImpl
|
class MusicSettingsImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) :
|
||||||
@ApplicationContext context: Context,
|
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
||||||
val documentTreePathFactory: DocumentTreePathFactory
|
|
||||||
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
|
||||||
private val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
private val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||||
|
|
||||||
override var musicDirs: MusicDirectories
|
override var musicDirs: MusicDirectories
|
||||||
|
@ -68,7 +66,7 @@ constructor(
|
||||||
val dirs =
|
val dirs =
|
||||||
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
|
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
|
||||||
?: emptySet())
|
?: emptySet())
|
||||||
.mapNotNull(documentTreePathFactory::deserializeDocumentTreePath)
|
.mapNotNull(documentPathFactory::fromDocumentId)
|
||||||
return MusicDirectories(
|
return MusicDirectories(
|
||||||
dirs,
|
dirs,
|
||||||
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
|
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
|
||||||
|
@ -77,7 +75,7 @@ constructor(
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putStringSet(
|
putStringSet(
|
||||||
getString(R.string.set_key_music_dirs),
|
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)
|
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
|
|
|
@ -141,6 +141,11 @@ constructor(
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
||||||
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
|
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
|
||||||
|
|
||||||
|
if (songs.isEmpty()) {
|
||||||
|
_importError.put(Unit)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
createPlaylist(importedPlaylist.name, songs)
|
createPlaylist(importedPlaylist.name, songs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,14 +18,8 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.dirs
|
package org.oxycblt.auxio.music.dirs
|
||||||
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
|
||||||
@Module
|
@Module @InstallIn(SingletonComponent::class) interface DirectoryModule {}
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface DirectoryModule {
|
|
||||||
@Binds
|
|
||||||
fun documentTreePathFactory(factory: DocumentTreePathFactoryImpl): DocumentTreePathFactory
|
|
||||||
}
|
|
||||||
|
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
||||||
import org.oxycblt.auxio.music.MusicSettings
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.fs.DocumentPathFactory
|
||||||
import org.oxycblt.auxio.music.fs.Path
|
import org.oxycblt.auxio.music.fs.Path
|
||||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -48,7 +49,7 @@ class MusicDirsDialog :
|
||||||
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
||||||
private val dirAdapter = DirectoryAdapter(this)
|
private val dirAdapter = DirectoryAdapter(this)
|
||||||
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
|
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
|
||||||
@Inject lateinit var documentTreePathFactory: DocumentTreePathFactory
|
@Inject lateinit var documentPathFactory: DocumentPathFactory
|
||||||
@Inject lateinit var musicSettings: MusicSettings
|
@Inject lateinit var musicSettings: MusicSettings
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||||
|
@ -101,8 +102,7 @@ class MusicDirsDialog :
|
||||||
if (pendingDirs != null) {
|
if (pendingDirs != null) {
|
||||||
dirs =
|
dirs =
|
||||||
MusicDirectories(
|
MusicDirectories(
|
||||||
pendingDirs.mapNotNull(
|
pendingDirs.mapNotNull(documentPathFactory::fromDocumentId),
|
||||||
documentTreePathFactory::deserializeDocumentTreePath),
|
|
||||||
savedInstanceState.getBoolean(KEY_PENDING_MODE))
|
savedInstanceState.getBoolean(KEY_PENDING_MODE))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -126,8 +126,7 @@ class MusicDirsDialog :
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
outState.putStringArrayList(
|
outState.putStringArrayList(
|
||||||
KEY_PENDING_DIRS,
|
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId)))
|
||||||
ArrayList(dirAdapter.dirs.map(documentTreePathFactory::serializeDocumentTreePath)))
|
|
||||||
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
|
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,7 +154,7 @@ class MusicDirsDialog :
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val dir = documentTreePathFactory.unpackDocumentTreeUri(uri)
|
val dir = documentPathFactory.unpackDocumentTreeUri(uri)
|
||||||
|
|
||||||
if (dir != null) {
|
if (dir != null) {
|
||||||
dirAdapter.add(dir)
|
dirAdapter.add(dir)
|
||||||
|
|
|
@ -24,39 +24,81 @@ import java.io.InputStreamReader
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.fs.Components
|
import org.oxycblt.auxio.music.fs.Components
|
||||||
import org.oxycblt.auxio.music.fs.Path
|
import org.oxycblt.auxio.music.fs.Path
|
||||||
|
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal M3U file format implementation.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
interface M3U {
|
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 {
|
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 reader = BufferedReader(InputStreamReader(stream))
|
||||||
val media = mutableListOf<Path>()
|
val paths = mutableListOf<Path>()
|
||||||
|
var name: String? = null
|
||||||
|
|
||||||
consumeFile@ while (true) {
|
consumeFile@ while (true) {
|
||||||
|
var path: String?
|
||||||
collectMetadata@ while (true) {
|
collectMetadata@ while (true) {
|
||||||
val line = reader.readLine() ?: break@consumeFile
|
// The M3U format consists of "entries" that begin with a bunch of metadata
|
||||||
if (!line.startsWith("#")) {
|
// 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
|
break@collectMetadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val path = reader.readLine()
|
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
logW("Expected a path, instead got an EOF")
|
logW("Expected a path, instead got an EOF")
|
||||||
break@consumeFile
|
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 relativeComponents = Components.parse(path)
|
||||||
val absoluteComponents =
|
val absoluteComponents =
|
||||||
resolveRelativePath(relativeComponents, workingDirectory.components)
|
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(
|
private fun resolveRelativePath(
|
||||||
|
@ -66,8 +108,12 @@ class M3UImpl @Inject constructor() : M3U {
|
||||||
var components = workingDirectory
|
var components = workingDirectory
|
||||||
for (component in relative.components) {
|
for (component in relative.components) {
|
||||||
when (component) {
|
when (component) {
|
||||||
|
// Parent specifier, go "back" one directory (in practice cleave off the last
|
||||||
|
// component)
|
||||||
".." -> components = components.parent()
|
".." -> components = components.parent()
|
||||||
|
// Current directory, the components are already there.
|
||||||
"." -> {}
|
"." -> {}
|
||||||
|
// New directory, add it
|
||||||
else -> components = components.child(component)
|
else -> components = components.child(component)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,28 +22,42 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
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.Path
|
||||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic playlist file importing abstraction.
|
||||||
|
*
|
||||||
|
* @see ImportedPlaylist
|
||||||
|
* @see M3U
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
interface PlaylistImporter {
|
interface PlaylistImporter {
|
||||||
suspend fun import(uri: Uri): ImportedPlaylist?
|
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>)
|
data class ImportedPlaylist(val name: String?, val paths: List<Path>)
|
||||||
|
|
||||||
class PlaylistImporterImpl
|
class PlaylistImporterImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val contentPathResolver: ContentPathResolver,
|
private val documentPathFactory: DocumentPathFactory,
|
||||||
private val m3u: M3U
|
private val m3u: M3U
|
||||||
) : PlaylistImporter {
|
) : PlaylistImporter {
|
||||||
override suspend fun import(uri: Uri): ImportedPlaylist? {
|
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 {
|
return context.contentResolverSafe.openInputStream(uri)?.use {
|
||||||
val paths = m3u.read(it, workingDirectory) ?: return null
|
return m3u.read(it, filePath.directory)
|
||||||
return ImportedPlaylist(null, paths)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,18 +22,13 @@ import android.net.Uri
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
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
|
* A factory for parsing the reverse-engineered format of the URIs obtained from document picker.
|
||||||
* (i.e directory) folder.
|
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface DocumentTreePathFactory {
|
interface DocumentPathFactory {
|
||||||
/**
|
/**
|
||||||
* Unpacks a document URI into a [Path] instance, using [fromDocumentId].
|
* Unpacks a document URI into a [Path] instance, using [fromDocumentId].
|
||||||
*
|
*
|
||||||
|
@ -67,8 +62,8 @@ interface DocumentTreePathFactory {
|
||||||
fun fromDocumentId(path: String): Path?
|
fun fromDocumentId(path: String): Path?
|
||||||
}
|
}
|
||||||
|
|
||||||
class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
|
class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
|
||||||
DocumentTreePathFactory {
|
DocumentPathFactory {
|
||||||
override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri))
|
override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri))
|
||||||
|
|
||||||
override fun unpackDocumentTreeUri(uri: Uri): Path? {
|
override fun unpackDocumentTreeUri(uri: Uri): Path? {
|
||||||
|
|
|
@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.fs
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
@ -39,11 +40,14 @@ class FsModule {
|
||||||
fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) =
|
fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) =
|
||||||
MediaStoreExtractor.from(context, volumeManager)
|
MediaStoreExtractor.from(context, volumeManager)
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) =
|
|
||||||
ContentPathResolver.from(context, volumeManager)
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
|
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
|
||||||
context.contentResolverSafe
|
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_no_music">No music found</string>
|
||||||
<string name="err_index_failed">Music loading failed</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_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>
|
<string name="err_no_app">No app found that can handle this task</string>
|
||||||
<!-- No folders in the "Music Folders" setting -->
|
<!-- No folders in the "Music Folders" setting -->
|
||||||
<string name="err_no_dirs">No folders</string>
|
<string name="err_no_dirs">No folders</string>
|
||||||
|
|
Loading…
Reference in a new issue