feat: manual song import via SAF picker (copy to library + tag read + dedupe)
This commit is contained in:
parent
72e55ba319
commit
7372d30943
382 changed files with 383 additions and 69 deletions
133
app/src/main/java/com/rada/data/repository/SongImporter.kt
Normal file
133
app/src/main/java/com/rada/data/repository/SongImporter.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue