feat: manual song import via SAF picker (copy to library + tag read + dedupe)

This commit is contained in:
dev 2026-09-07 11:08:57 -05:00
commit 7372d30943
382 changed files with 383 additions and 69 deletions

View file

@ -0,0 +1,133 @@
package com.rada.data.repository
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import com.rada.data.local.entity.SavedSongEntity
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class ImportResult(
val imported: Int,
val skipped: Int,
val errors: List<String> = emptyList(),
)
/**
* Manual song import: copies user-picked files (via SAF) into the library
* directory and registers them in Room. Nothing is scanned automatically.
*/
@Singleton
class SongImporter @Inject constructor(
@ApplicationContext private val context: Context,
private val libraryRepository: LibraryRepository,
) {
suspend fun import(uris: List<Uri>): ImportResult {
var imported = 0
var skipped = 0
val errors = mutableListOf<String>()
val dir = getImportDirectory()
if (!dir.exists() && !dir.mkdirs()) {
return ImportResult(0, 0, listOf("Cannot create import directory: ${dir.absolutePath}"))
}
for (uri in uris) {
val displayName = (queryDisplayName(uri) ?: "song-${System.currentTimeMillis()}")
.replace('/', '_').replace('\\', '_').ifBlank { "song" }
try {
val base = displayName.substringBeforeLast('.')
val ext = displayName.substringAfterLast('.', "mp3").lowercase()
val format = if (ext in setOf("mp3", "flac", "ogg", "m4a", "aac", "wav", "opus")) ext else "mp3"
// Never clobber an existing library entry; make a unique name instead.
var target = File(dir, "$base.$format")
var counter = 1
while (libraryRepository.getByPath(target.absolutePath) != null || target.exists()) {
target = File(dir, "$base ($counter).$format")
counter++
}
val input = context.contentResolver.openInputStream(uri)
?: throw IllegalStateException("Cannot open picked file")
input.use { src -> target.outputStream().use { out -> src.copyTo(out) } }
val (artist, title, album, durationMs) = readTags(target, base)
val id = libraryRepository.insert(
SavedSongEntity(
fileName = target.name,
filePath = target.absolutePath,
artist = artist,
title = title,
album = album,
stationName = "",
durationMs = durationMs,
fileSize = target.length(),
format = format,
)
)
if (id > 0) imported++ else skipped++
} catch (e: Exception) {
Log.e(TAG, "Import failed for $displayName", e)
errors += "$displayName: ${e.message ?: e.javaClass.simpleName}"
}
}
return ImportResult(imported, skipped, errors)
}
private fun readTags(file: File, fallbackBase: String): TagInfo {
var artist = ""
var title = ""
var album = ""
var durationMs = 0L
try {
val mmr = MediaMetadataRetriever()
mmr.setDataSource(file.absolutePath)
artist = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)?.trim().orEmpty()
title = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim().orEmpty()
album = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM)?.trim().orEmpty()
durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
mmr.release()
} catch (_: Exception) {
// Untaggable file: fall through to name-based parsing.
}
if (title.isEmpty()) {
// "Artist - Title" convention, else whole name as title.
val parts = fallbackBase.split(" - ", limit = 2)
if (parts.size == 2 && parts[0].isNotBlank()) {
artist = parts[0].trim()
title = parts[1].trim()
} else {
title = fallbackBase.trim()
}
}
if (artist.isEmpty()) artist = "Imported"
return TagInfo(artist, title, album, durationMs)
}
private data class TagInfo(val artist: String, val title: String, val album: String, val durationMs: Long)
private fun queryDisplayName(uri: Uri): String? {
return try {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(0) else null
}
} catch (_: Exception) {
null
}
}
private fun getImportDirectory(): File {
val musicDir = context.getExternalFilesDir(android.os.Environment.DIRECTORY_MUSIC)
?: context.filesDir
return File(musicDir, "RadAmp/Imported")
}
companion object {
private const val TAG = "SongImporter"
}
}

View file

@ -1,5 +1,7 @@
package com.rada.ui.library
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -23,6 +25,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MusicNote
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
@ -34,6 +37,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
@ -77,10 +82,27 @@ fun LibraryScreen(
val uiState by viewModel.uiState.collectAsState()
val filteredPlaylists by viewModel.filteredPlaylists.collectAsState()
val allPlaylists by viewModel.allPlaylists.collectAsState()
val importMessage by viewModel.importMessage.collectAsState()
var showSearch by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showSortMenu by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
// Manual import: user picks audio files in the system picker; we copy them in.
val importPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
) { uris ->
if (uris.isNotEmpty()) viewModel.importSongs(uris)
}
LaunchedEffect(importMessage) {
importMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearImportMessage()
}
}
LaunchedEffect(initialPlaylistId) {
if (initialPlaylistId != null) {
viewModel.selectPlaylist(initialPlaylistId)
@ -108,6 +130,13 @@ 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,
)
}
if (uiState.selectedPlaylistId != null && uiState.songs.isNotEmpty()) {
IconButton(onClick = { viewModel.shufflePlay() }) {
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
@ -122,6 +151,7 @@ fun LibraryScreen(
),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
Column(
@ -233,7 +263,8 @@ fun LibraryScreen(
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (uiState.selectedPlaylistId != null) "Playlist is empty" else "No saved songs yet",
text = if (uiState.selectedPlaylistId != null) "Playlist is empty"
else "No saved songs yet — tap + to import songs from your device",
style = MaterialTheme.typography.bodyLarge,
color = PremiumTextSecondary,
)

View file

@ -7,6 +7,7 @@ import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.LibraryRepository
import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.SortOrder
import com.rada.data.repository.SongImporter
import com.rada.player.PlaybackManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -28,11 +29,38 @@ class LibraryViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
private val playlistRepository: PlaylistRepository,
private val playbackManager: PlaybackManager,
private val songImporter: SongImporter,
) : ViewModel() {
private val _uiState = MutableStateFlow(LibraryUiState())
val uiState: StateFlow<LibraryUiState> = _uiState.asStateFlow()
private val _importMessage = MutableStateFlow<String?>(null)
val importMessage: StateFlow<String?> = _importMessage.asStateFlow()
/** Manual import: copies user-picked audio files into the library. */
fun importSongs(uris: List<android.net.Uri>) {
if (uris.isEmpty()) return
_uiState.update { it.copy(isImporting = true) }
viewModelScope.launch {
val result = songImporter.import(uris)
_uiState.update { it.copy(isImporting = false) }
_importMessage.value = buildString {
append("Imported ${result.imported} song")
if (result.imported != 1) append("s")
if (result.errors.isNotEmpty()) {
append(" (${result.errors.size} failed: ${result.errors.first()}")
if (result.errors.size > 1) append(", +${result.errors.size - 1} more")
append(")")
}
}
}
}
fun clearImportMessage() {
_importMessage.value = null
}
private val searchQuery = MutableStateFlow("")
private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED)
private val selectedPlaylistId = MutableStateFlow<Long?>(null)
@ -173,6 +201,7 @@ data class LibraryUiState(
val selectedPlaylistId: Long? = null,
val shuffleQueue: List<SavedSongEntity> = emptyList(),
val shuffleIndex: Int = -1,
val isImporting: Boolean = false,
) {
val isShuffleMode: Boolean get() = shuffleQueue.isNotEmpty() && shuffleIndex >= 0
}