feat: library UX — long-press folder tab to delete (group only or +songs), import dedupe skips duplicates, always-visible sort row with A-Z/Z-A toggle

This commit is contained in:
avi 2026-09-07 12:29:58 -05:00
commit 38b36687ee
7 changed files with 202 additions and 43 deletions

View file

@ -54,4 +54,13 @@ interface PlaylistDao {
@Query("SELECT ps.playlistId FROM playlist_songs ps WHERE ps.songId = :songId") @Query("SELECT ps.playlistId FROM playlist_songs ps WHERE ps.songId = :songId")
suspend fun getPlaylistIdsForSong(songId: Long): List<Long> suspend fun getPlaylistIdsForSong(songId: Long): List<Long>
@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<SavedSongEntity>
} }

View file

@ -32,6 +32,9 @@ interface SavedSongDao {
@Query("SELECT * FROM saved_songs WHERE filePath = :path LIMIT 1") @Query("SELECT * FROM saved_songs WHERE filePath = :path LIMIT 1")
suspend fun getByPath(path: String): SavedSongEntity? 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 @Insert
suspend fun insert(song: SavedSongEntity): Long suspend fun insert(song: SavedSongEntity): Long

View file

@ -38,6 +38,9 @@ class LibraryRepository @Inject constructor(
suspend fun getByPath(path: String): SavedSongEntity? = savedSongDao.getByPath(path) 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 insert(song: SavedSongEntity): Long = savedSongDao.insert(song)
suspend fun update(song: SavedSongEntity) = savedSongDao.update(song) suspend fun update(song: SavedSongEntity) = savedSongDao.update(song)

View file

@ -27,6 +27,10 @@ class PlaylistRepository @Inject constructor(
suspend fun deletePlaylistById(id: Long) = playlistDao.deletePlaylistById(id) 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<SavedSongEntity> =
playlistDao.getSongsInPlaylistOnce(playlistId)
suspend fun addSongToPlaylist(playlistId: Long, songId: Long) { suspend fun addSongToPlaylist(playlistId: Long, songId: Long) {
playlistDao.addSongToPlaylist( playlistDao.addSongToPlaylist(
PlaylistSongEntity(playlistId = playlistId, songId = songId) PlaylistSongEntity(playlistId = playlistId, songId = songId)

View file

@ -133,23 +133,42 @@ class SongImporter @Inject constructor(
val displayName = (queryDisplayName(uri) ?: "song-${System.currentTimeMillis()}") val displayName = (queryDisplayName(uri) ?: "song-${System.currentTimeMillis()}")
.replace('/', '_').replace('\\', '_').ifBlank { "song" } .replace('/', '_').replace('\\', '_').ifBlank { "song" }
try { try {
val base = displayName.substringBeforeLast('.') // Read tags from the source stream first so duplicate detection
val ext = displayName.substringAfterLast('.', "mp3").lowercase() // works before anything is copied into the library.
val format = if (ext in setOf("mp3", "flac", "ogg", "m4a", "aac", "wav", "opus")) ext else "mp3" val staged = File(dir, ".staging_${System.nanoTime()}")
// 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) val input = context.contentResolver.openInputStream(uri)
?: throw IllegalStateException("Cannot open picked file") ?: 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( val songId = libraryRepository.insert(
SavedSongEntity( SavedSongEntity(
fileName = target.name, fileName = target.name,

View file

@ -2,9 +2,10 @@ package com.rada.ui.library
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Delete
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder 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.PlaylistAdd
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -44,6 +47,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.SavedSongEntity import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.SortOrder import com.rada.data.repository.SortOrder
import com.rada.ui.theme.PremiumBorder 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.PremiumText
import com.rada.ui.theme.PremiumTextSecondary import com.rada.ui.theme.PremiumTextSecondary
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
onBack: () -> Unit, onBack: () -> Unit,
@ -87,8 +92,8 @@ fun LibraryScreen(
val importMessage by viewModel.importMessage.collectAsState() val importMessage by viewModel.importMessage.collectAsState()
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var showSortMenu by remember { mutableStateOf(false) }
var showImportMenu by remember { mutableStateOf(false) } var showImportMenu by remember { mutableStateOf(false) }
var playlistToDelete by remember { mutableStateOf<PlaylistEntity?>(null) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -173,9 +178,6 @@ fun LibraryScreen(
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play") Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
} }
} }
IconButton(onClick = { showSortMenu = !showSortMenu }) {
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort")
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@ -208,7 +210,7 @@ fun LibraryScreen(
) { ) {
tabLabels.forEachIndexed { index, label -> tabLabels.forEachIndexed { index, label ->
Tab( Tab(
selected = selectedTabIndex == index, modifier = Modifier.combinedClickable(
onClick = { onClick = {
if (index == 0) { if (index == 0) {
viewModel.selectPlaylist(null) viewModel.selectPlaylist(null)
@ -216,6 +218,13 @@ fun LibraryScreen(
viewModel.selectPlaylist(filteredPlaylists[index - 1].id) 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 = { },
text = { text = {
Text( Text(
text = label, text = label,
@ -258,24 +267,30 @@ fun LibraryScreen(
shape = RoundedCornerShape(12.dp), 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp)
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.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) viewModel.setSortOrder(SortOrder.DATE_SAVED)
showSortMenu = false
} }
SortChip("Artist", uiState.songs.isNotEmpty()) { SortChip("Artist", uiState.sortOrder == SortOrder.ARTIST, uiState.sortAscending) {
viewModel.setSortOrder(SortOrder.ARTIST) viewModel.setSortOrder(SortOrder.ARTIST)
showSortMenu = false
} }
SortChip("Title", uiState.songs.isNotEmpty()) { SortChip("Title", uiState.sortOrder == SortOrder.TITLE, uiState.sortAscending) {
viewModel.setSortOrder(SortOrder.TITLE) 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 @Composable
private fun SortChip( private fun SortChip(
label: String, label: String,
enabled: Boolean, selected: Boolean,
ascending: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
FilterChip( FilterChip(
selected = false, selected = selected,
onClick = onClick, 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) }, label = { Text(label) },
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
containerColor = PremiumSurface, containerColor = PremiumSurface,
labelColor = PremiumTextSecondary, labelColor = PremiumTextSecondary,
disabledContainerColor = PremiumSurfaceVariant, selectedContainerColor = PremiumPurple.copy(alpha = 0.22f),
disabledLabelColor = PremiumTextSecondary.copy(alpha = 0.5f), selectedLabelColor = PremiumPurpleLight,
),
border = FilterChipDefaults.filterChipBorder(
borderColor = PremiumBorder,
selectedBorderColor = PremiumPurple,
enabled = true,
selected = selected,
), ),
) )
} }

View file

@ -48,6 +48,9 @@ class LibraryViewModel @Inject constructor(
_importMessage.value = buildString { _importMessage.value = buildString {
append("Imported ${result.imported} song") append("Imported ${result.imported} song")
if (result.imported != 1) append("s") if (result.imported != 1) append("s")
if (result.skipped > 0) {
append(" · ${result.skipped} skipped (already in library)")
}
if (result.folderPlaylists.isNotEmpty()) { if (result.folderPlaylists.isNotEmpty()) {
append(" → folder") append(" → folder")
if (result.folderPlaylists.size > 1) append("s") if (result.folderPlaylists.size > 1) append("s")
@ -68,6 +71,7 @@ class LibraryViewModel @Inject constructor(
private val searchQuery = MutableStateFlow("") private val searchQuery = MutableStateFlow("")
private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED) private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED)
private val sortAscending = MutableStateFlow(true)
private val selectedPlaylistId = MutableStateFlow<Long?>(null) private val selectedPlaylistId = MutableStateFlow<Long?>(null)
val allPlaylists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists() val allPlaylists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
@ -94,12 +98,44 @@ class LibraryViewModel @Inject constructor(
else -> libraryRepository.search(query) 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 { viewModelScope.launch {
songsFlow.collect { songs -> combine(songsFlow, sortOrder, sortAscending) { songs, sort, ascending ->
_uiState.update { it.copy(songs = songs, isLoading = false) } 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<SavedSongEntity>,
order: SortOrder,
ascending: Boolean,
): List<SavedSongEntity> {
val byArtist: Comparator<SavedSongEntity> =
compareBy(String.CASE_INSENSITIVE_ORDER) { s: SavedSongEntity -> s.artist }
.thenBy(String.CASE_INSENSITIVE_ORDER) { s: SavedSongEntity -> s.title }
val byTitle: Comparator<SavedSongEntity> =
compareBy(String.CASE_INSENSITIVE_ORDER) { s: SavedSongEntity -> s.title }
.thenBy(String.CASE_INSENSITIVE_ORDER) { s: SavedSongEntity -> s.artist }
val byDate: Comparator<SavedSongEntity> =
compareBy<SavedSongEntity> { 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) { fun playSong(song: SavedSongEntity) {
playbackManager.playLocalFile( playbackManager.playLocalFile(
@ -176,7 +212,17 @@ class LibraryViewModel @Inject constructor(
} }
fun setSortOrder(order: SortOrder) { fun setSortOrder(order: SortOrder) {
// Tapping the active field flips direction (like every other music app).
if (sortOrder.value == order) {
sortAscending.value = !sortAscending.value
} else {
sortOrder.value = order sortOrder.value = order
sortAscending.value = true
}
}
fun toggleSortDirection() {
sortAscending.value = !sortAscending.value
} }
fun clearSearch() { fun clearSearch() {
@ -188,8 +234,20 @@ class LibraryViewModel @Inject constructor(
_uiState.update { it.copy(selectedPlaylistId = playlistId) } _uiState.update { it.copy(selectedPlaylistId = playlistId) }
} }
fun deletePlaylist(playlist: PlaylistEntity) { fun deletePlaylist(playlist: PlaylistEntity, deleteSongs: Boolean = false) {
viewModelScope.launch { 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) playlistRepository.deletePlaylist(playlist)
if (selectedPlaylistId.value == playlist.id) { if (selectedPlaylistId.value == playlist.id) {
selectedPlaylistId.value = null selectedPlaylistId.value = null
@ -204,6 +262,8 @@ data class LibraryUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val currentPlayingId: Long? = null, val currentPlayingId: Long? = null,
val selectedPlaylistId: Long? = null, val selectedPlaylistId: Long? = null,
val sortOrder: SortOrder = SortOrder.DATE_SAVED,
val sortAscending: Boolean = true,
val shuffleQueue: List<SavedSongEntity> = emptyList(), val shuffleQueue: List<SavedSongEntity> = emptyList(),
val shuffleIndex: Int = -1, val shuffleIndex: Int = -1,
val isImporting: Boolean = false, val isImporting: Boolean = false,