fix: folder import — treat picked tree URIs via fromTreeUri (was misread as file -> Invalid URI)
This commit is contained in:
parent
7372d30943
commit
c0aea3eabc
245 changed files with 155 additions and 58 deletions
|
|
@ -5,6 +5,7 @@ import android.media.MediaMetadataRetriever
|
|||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
|
|
@ -18,8 +19,9 @@ data class ImportResult(
|
|||
)
|
||||
|
||||
/**
|
||||
* Manual song import: copies user-picked files (via SAF) into the library
|
||||
* directory and registers them in Room. Nothing is scanned automatically.
|
||||
* Manual song import: copies user-picked files or whole folders (via SAF)
|
||||
* into the library directory and registers them in Room.
|
||||
* Nothing is scanned automatically.
|
||||
*/
|
||||
@Singleton
|
||||
class SongImporter @Inject constructor(
|
||||
|
|
@ -27,7 +29,60 @@ class SongImporter @Inject constructor(
|
|||
private val libraryRepository: LibraryRepository,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Entry point. Each uri may be a single document or a picked tree/folder;
|
||||
* trees are walked recursively for audio files.
|
||||
*/
|
||||
suspend fun import(uris: List<Uri>): ImportResult {
|
||||
val filesToImport = mutableListOf<Uri>()
|
||||
for (uri in uris) {
|
||||
// Folder picks arrive as tree URIs (…/tree/primary:…); file picks
|
||||
// arrive as document URIs. fromSingleUri() misreads tree URIs as
|
||||
// files, so detect trees by their path first.
|
||||
val isTreeUri = uri.path?.startsWith("/tree/") == true
|
||||
val doc = try {
|
||||
if (isTreeUri) DocumentFile.fromTreeUri(context, uri)
|
||||
else DocumentFile.fromSingleUri(context, uri)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
when {
|
||||
doc == null && !isTreeUri -> filesToImport += uri
|
||||
doc == null -> Log.w(TAG, "Cannot open picked tree: $uri")
|
||||
doc.isDirectory -> collectAudioFromTree(doc, filesToImport)
|
||||
else -> filesToImport += uri
|
||||
}
|
||||
}
|
||||
if (filesToImport.isEmpty()) {
|
||||
return ImportResult(0, 0, listOf("No audio files found in the picked selection."))
|
||||
}
|
||||
return importFiles(filesToImport)
|
||||
}
|
||||
|
||||
private fun collectAudioFromTree(dir: DocumentFile, out: MutableList<Uri>) {
|
||||
try {
|
||||
for (child in dir.listFiles()) {
|
||||
if (child.isDirectory) {
|
||||
collectAudioFromTree(child, out)
|
||||
} else if (isAudio(child)) {
|
||||
out += child.uri
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list folder ${dir.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAudio(doc: DocumentFile): Boolean {
|
||||
val mime = doc.type
|
||||
if (mime != null && mime.startsWith("audio/")) return true
|
||||
val name = doc.name?.lowercase().orEmpty()
|
||||
return name.substringAfterLast('.', "").let { ext ->
|
||||
ext in setOf("mp3", "flac", "ogg", "oga", "opus", "m4a", "aac", "wav")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun importFiles(uris: List<Uri>): ImportResult {
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
val errors = mutableListOf<String>()
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import androidx.compose.material.icons.filled.PlaylistAdd
|
|||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
|
|
@ -86,6 +88,7 @@ fun LibraryScreen(
|
|||
var showSearch by remember { mutableStateOf(false) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
var showImportMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
|
|
@ -96,6 +99,13 @@ fun LibraryScreen(
|
|||
if (uris.isNotEmpty()) viewModel.importSongs(uris)
|
||||
}
|
||||
|
||||
// Folder import: pick a folder; all audio files inside are copied in.
|
||||
val folderPicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocumentTree()
|
||||
) { treeUri ->
|
||||
if (treeUri != null) viewModel.importSongs(listOf(treeUri))
|
||||
}
|
||||
|
||||
LaunchedEffect(importMessage) {
|
||||
importMessage?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
|
|
@ -130,12 +140,33 @@ fun LibraryScreen(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { importPicker.launch(arrayOf("audio/*")) }, enabled = !uiState.isImporting) {
|
||||
Icon(
|
||||
Icons.Default.PlaylistAdd,
|
||||
contentDescription = "Import songs",
|
||||
tint = if (uiState.isImporting) PremiumTextSecondary.copy(alpha = 0.4f) else PremiumPurple,
|
||||
)
|
||||
Box {
|
||||
IconButton(onClick = { showImportMenu = true }, enabled = !uiState.isImporting) {
|
||||
Icon(
|
||||
Icons.Default.PlaylistAdd,
|
||||
contentDescription = "Import songs",
|
||||
tint = if (uiState.isImporting) PremiumTextSecondary.copy(alpha = 0.4f) else PremiumPurple,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showImportMenu,
|
||||
onDismissRequest = { showImportMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Import songs…") },
|
||||
onClick = {
|
||||
showImportMenu = false
|
||||
importPicker.launch(arrayOf("audio/*"))
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Import folder…") },
|
||||
onClick = {
|
||||
showImportMenu = false
|
||||
folderPicker.launch(null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (uiState.selectedPlaylistId != null && uiState.songs.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.shufflePlay() }) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue