diff --git a/app/src/main/java/com/rada/data/local/dao/PlaylistDao.kt b/app/src/main/java/com/rada/data/local/dao/PlaylistDao.kt index 3545cd0..caf0b30 100644 --- a/app/src/main/java/com/rada/data/local/dao/PlaylistDao.kt +++ b/app/src/main/java/com/rada/data/local/dao/PlaylistDao.kt @@ -54,4 +54,13 @@ interface PlaylistDao { @Query("SELECT ps.playlistId FROM playlist_songs ps WHERE ps.songId = :songId") suspend fun getPlaylistIdsForSong(songId: Long): List + + @Query( + """ + SELECT s.* FROM saved_songs s + INNER JOIN playlist_songs ps ON s.id = ps.songId + WHERE ps.playlistId = :playlistId + """ + ) + suspend fun getSongsInPlaylistOnce(playlistId: Long): List } diff --git a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt index d877768..6a76890 100644 --- a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt +++ b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt @@ -32,6 +32,9 @@ interface SavedSongDao { @Query("SELECT * FROM saved_songs WHERE filePath = :path LIMIT 1") suspend fun getByPath(path: String): SavedSongEntity? + @Query("SELECT * FROM saved_songs WHERE artist = :artist AND title = :title COLLATE NOCASE LIMIT 1") + suspend fun getByArtistTitle(artist: String, title: String): SavedSongEntity? + @Insert suspend fun insert(song: SavedSongEntity): Long diff --git a/app/src/main/java/com/rada/data/repository/LibraryRepository.kt b/app/src/main/java/com/rada/data/repository/LibraryRepository.kt index 4cffe0d..cdc8acd 100644 --- a/app/src/main/java/com/rada/data/repository/LibraryRepository.kt +++ b/app/src/main/java/com/rada/data/repository/LibraryRepository.kt @@ -38,6 +38,9 @@ class LibraryRepository @Inject constructor( suspend fun getByPath(path: String): SavedSongEntity? = savedSongDao.getByPath(path) + suspend fun getByArtistTitle(artist: String, title: String): SavedSongEntity? = + savedSongDao.getByArtistTitle(artist, title) + suspend fun insert(song: SavedSongEntity): Long = savedSongDao.insert(song) suspend fun update(song: SavedSongEntity) = savedSongDao.update(song) diff --git a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt index 91f4c94..24f8f28 100644 --- a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt +++ b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt @@ -27,6 +27,10 @@ class PlaylistRepository @Inject constructor( suspend fun deletePlaylistById(id: Long) = playlistDao.deletePlaylistById(id) + /** Snapshot of a playlist's songs (for delete-with-songs flows). */ + suspend fun getSongsInPlaylistOnce(playlistId: Long): List = + playlistDao.getSongsInPlaylistOnce(playlistId) + suspend fun addSongToPlaylist(playlistId: Long, songId: Long) { playlistDao.addSongToPlaylist( PlaylistSongEntity(playlistId = playlistId, songId = songId) diff --git a/app/src/main/java/com/rada/data/repository/SongImporter.kt b/app/src/main/java/com/rada/data/repository/SongImporter.kt index 801f8db..11fb5df 100644 --- a/app/src/main/java/com/rada/data/repository/SongImporter.kt +++ b/app/src/main/java/com/rada/data/repository/SongImporter.kt @@ -133,23 +133,42 @@ class SongImporter @Inject constructor( 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++ - } - + // Read tags from the source stream first so duplicate detection + // works before anything is copied into the library. + val staged = File(dir, ".staging_${System.nanoTime()}") val input = context.contentResolver.openInputStream(uri) ?: throw IllegalStateException("Cannot open picked file") - input.use { src -> target.outputStream().use { out -> src.copyTo(out) } } + input.use { src -> staged.outputStream().use { out -> src.copyTo(out) } } + + val base = displayName.substringBeforeLast('.') + val (artist, title, album, durationMs) = readTags(staged, base) + + // Skip duplicates: same artist + title (case-insensitive) already in library. + val existing = libraryRepository.getByArtistTitle(artist, title) + if (existing != null) { + staged.delete() + skipped++ + // Still group the existing song under this folder's tab. + if (playlistId != null && !playlistRepository.isSongInPlaylist(playlistId, existing.id)) { + playlistRepository.addSongToPlaylist(playlistId, existing.id) + } + continue + } + + val ext = displayName.substringAfterLast('.', "mp3").lowercase() + val format = if (ext in setOf("mp3", "flac", "ogg", "m4a", "aac", "wav", "opus")) ext else "mp3" + val target = File(dir, "$base.$format") + // Path collision only (same filename, different song): keep unique. + if (libraryRepository.getByPath(target.absolutePath) != null || target.exists()) { + staged.delete() + skipped++ + continue + } + if (!staged.renameTo(target)) { + staged.copyTo(target, overwrite = true) + staged.delete() + } - val (artist, title, album, durationMs) = readTags(target, base) val songId = libraryRepository.insert( SavedSongEntity( fileName = target.name, diff --git a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt index 580421a..04a7341 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -2,9 +2,10 @@ 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.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,6 +22,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder @@ -28,7 +31,7 @@ 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 +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -44,6 +47,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults @@ -64,6 +68,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.rada.data.local.entity.PlaylistEntity import com.rada.data.local.entity.SavedSongEntity import com.rada.data.repository.SortOrder import com.rada.ui.theme.PremiumBorder @@ -74,7 +79,7 @@ import com.rada.ui.theme.PremiumSurfaceVariant import com.rada.ui.theme.PremiumText import com.rada.ui.theme.PremiumTextSecondary -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun LibraryScreen( onBack: () -> Unit, @@ -87,8 +92,8 @@ fun LibraryScreen( val importMessage by viewModel.importMessage.collectAsState() var showSearch by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } - var showSortMenu by remember { mutableStateOf(false) } var showImportMenu by remember { mutableStateOf(false) } + var playlistToDelete by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } @@ -173,9 +178,6 @@ fun LibraryScreen( Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play") } } - IconButton(onClick = { showSortMenu = !showSortMenu }) { - Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort") - } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background, @@ -208,14 +210,21 @@ fun LibraryScreen( ) { tabLabels.forEachIndexed { index, label -> Tab( + modifier = Modifier.combinedClickable( + onClick = { + if (index == 0) { + viewModel.selectPlaylist(null) + } else { + viewModel.selectPlaylist(filteredPlaylists[index - 1].id) + } + }, + onLongClick = { + // Long-press a folder tab to delete the group. + if (index > 0) playlistToDelete = filteredPlaylists[index - 1] + }, + ), selected = selectedTabIndex == index, - onClick = { - if (index == 0) { - viewModel.selectPlaylist(null) - } else { - viewModel.selectPlaylist(filteredPlaylists[index - 1].id) - } - }, + onClick = { }, text = { Text( text = label, @@ -258,24 +267,30 @@ fun LibraryScreen( shape = RoundedCornerShape(12.dp), ) - AnimatedVisibility(visible = showSortMenu) { + // Always-visible sort row: current field highlighted; tapping it + // again flips A-Z / Z-A. Works inside folder tabs too. + if (uiState.songs.isNotEmpty()) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp) + .padding(bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - SortChip("Date", uiState.songs.isNotEmpty()) { + Text( + text = "Sort", + style = MaterialTheme.typography.labelMedium, + color = PremiumTextSecondary, + ) + SortChip("Recent", uiState.sortOrder == SortOrder.DATE_SAVED, uiState.sortAscending) { viewModel.setSortOrder(SortOrder.DATE_SAVED) - showSortMenu = false } - SortChip("Artist", uiState.songs.isNotEmpty()) { + SortChip("Artist", uiState.sortOrder == SortOrder.ARTIST, uiState.sortAscending) { viewModel.setSortOrder(SortOrder.ARTIST) - showSortMenu = false } - SortChip("Title", uiState.songs.isNotEmpty()) { + SortChip("Title", uiState.sortOrder == SortOrder.TITLE, uiState.sortAscending) { viewModel.setSortOrder(SortOrder.TITLE) - showSortMenu = false } } } @@ -318,24 +333,70 @@ fun LibraryScreen( } } } + + // Long-press a folder tab: clear choice between deleting just the group + // (songs stay in All Songs) or deleting its songs and files as well. + playlistToDelete?.let { playlist -> + AlertDialog( + onDismissRequest = { playlistToDelete = null }, + title = { Text("Delete \"${playlist.name}\"?") }, + text = { Text("You can delete just the folder group โ€” the songs stay in All Songs โ€” or delete the songs and their files too.") }, + confirmButton = { + Column(horizontalAlignment = Alignment.End) { + TextButton(onClick = { + viewModel.deletePlaylist(playlist, deleteSongs = false) + playlistToDelete = null + }) { + Text("Delete group only", color = PremiumText) + } + TextButton(onClick = { + viewModel.deletePlaylist(playlist, deleteSongs = true) + playlistToDelete = null + }) { + Text("Delete group + songs", color = MaterialTheme.colorScheme.error) + } + } + }, + dismissButton = { + TextButton(onClick = { playlistToDelete = null }) { + Text("Cancel", color = PremiumTextSecondary) + } + }, + ) + } } @Composable private fun SortChip( label: String, - enabled: Boolean, + selected: Boolean, + ascending: Boolean, onClick: () -> Unit, ) { FilterChip( - selected = false, + selected = selected, onClick = onClick, - enabled = enabled, + leadingIcon = if (selected) { + { + Icon( + if (ascending) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward, + contentDescription = if (ascending) "Ascending" else "Descending", + modifier = Modifier.size(16.dp), + ) + } + } else null, label = { Text(label) }, colors = FilterChipDefaults.filterChipColors( containerColor = PremiumSurface, labelColor = PremiumTextSecondary, - disabledContainerColor = PremiumSurfaceVariant, - disabledLabelColor = PremiumTextSecondary.copy(alpha = 0.5f), + selectedContainerColor = PremiumPurple.copy(alpha = 0.22f), + selectedLabelColor = PremiumPurpleLight, + ), + border = FilterChipDefaults.filterChipBorder( + borderColor = PremiumBorder, + selectedBorderColor = PremiumPurple, + enabled = true, + selected = selected, ), ) } diff --git a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt index f962687..1e186fc 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt @@ -48,6 +48,9 @@ class LibraryViewModel @Inject constructor( _importMessage.value = buildString { append("Imported ${result.imported} song") if (result.imported != 1) append("s") + if (result.skipped > 0) { + append(" ยท ${result.skipped} skipped (already in library)") + } if (result.folderPlaylists.isNotEmpty()) { append(" โ†’ folder") if (result.folderPlaylists.size > 1) append("s") @@ -68,6 +71,7 @@ class LibraryViewModel @Inject constructor( private val searchQuery = MutableStateFlow("") private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED) + private val sortAscending = MutableStateFlow(true) private val selectedPlaylistId = MutableStateFlow(null) val allPlaylists: StateFlow> = playlistRepository.getAllPlaylists() @@ -94,13 +98,45 @@ class LibraryViewModel @Inject constructor( else -> libraryRepository.search(query) } } + // Sort direction and in-folder sorting are applied here so every view + // (All Songs, folder tabs, search results) obeys the visible sort chips. viewModelScope.launch { - songsFlow.collect { songs -> - _uiState.update { it.copy(songs = songs, isLoading = false) } + combine(songsFlow, sortOrder, sortAscending) { songs, sort, ascending -> + Triple(sortSongs(songs, sort, ascending), sort, ascending) + }.collect { (sortedSongs, sort, ascending) -> + _uiState.update { + it.copy( + songs = sortedSongs, + isLoading = false, + sortOrder = sort, + sortAscending = ascending, + ) + } } } } + private fun sortSongs( + songs: List, + order: SortOrder, + ascending: Boolean, + ): List { + val byArtist: Comparator = + compareBy(String.CASE_INSENSITIVE_ORDER) { s: SavedSongEntity -> s.artist } + .thenBy(String.CASE_INSENSITIVE_ORDER) { s: SavedSongEntity -> s.title } + val byTitle: Comparator = + compareBy(String.CASE_INSENSITIVE_ORDER) { s: SavedSongEntity -> s.title } + .thenBy(String.CASE_INSENSITIVE_ORDER) { s: SavedSongEntity -> s.artist } + val byDate: Comparator = + compareBy { it.dateSaved }.thenBy { it.id } + val cmp = when (order) { + SortOrder.DATE_SAVED -> byDate + SortOrder.ARTIST -> byArtist + SortOrder.TITLE -> byTitle + } + return if (ascending) songs.sortedWith(cmp) else songs.sortedWith(cmp.reversed()) + } + fun playSong(song: SavedSongEntity) { playbackManager.playLocalFile( filePath = song.filePath, @@ -176,7 +212,17 @@ class LibraryViewModel @Inject constructor( } fun setSortOrder(order: SortOrder) { - sortOrder.value = order + // Tapping the active field flips direction (like every other music app). + if (sortOrder.value == order) { + sortAscending.value = !sortAscending.value + } else { + sortOrder.value = order + sortAscending.value = true + } + } + + fun toggleSortDirection() { + sortAscending.value = !sortAscending.value } fun clearSearch() { @@ -188,8 +234,20 @@ class LibraryViewModel @Inject constructor( _uiState.update { it.copy(selectedPlaylistId = playlistId) } } - fun deletePlaylist(playlist: PlaylistEntity) { + fun deletePlaylist(playlist: PlaylistEntity, deleteSongs: Boolean = false) { viewModelScope.launch { + if (deleteSongs) { + // Remove the songs (rows + files) before dropping the group; + // playlist_songs links cascade away with the rows. + val songs = playlistRepository.getSongsInPlaylistOnce(playlist.id) + for (song in songs) { + libraryRepository.delete(song) + try { + val file = java.io.File(song.filePath) + if (file.exists()) file.delete() + } catch (_: Exception) {} + } + } playlistRepository.deletePlaylist(playlist) if (selectedPlaylistId.value == playlist.id) { selectedPlaylistId.value = null @@ -204,6 +262,8 @@ data class LibraryUiState( val isLoading: Boolean = true, val currentPlayingId: Long? = null, val selectedPlaylistId: Long? = null, + val sortOrder: SortOrder = SortOrder.DATE_SAVED, + val sortAscending: Boolean = true, val shuffleQueue: List = emptyList(), val shuffleIndex: Int = -1, val isImporting: Boolean = false,