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:
parent
e6f88d5740
commit
38b36687ee
7 changed files with 202 additions and 43 deletions
|
|
@ -54,4 +54,13 @@ interface PlaylistDao {
|
|||
|
||||
@Query("SELECT ps.playlistId FROM playlist_songs ps WHERE ps.songId = :songId")
|
||||
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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<SavedSongEntity> =
|
||||
playlistDao.getSongsInPlaylistOnce(playlistId)
|
||||
|
||||
suspend fun addSongToPlaylist(playlistId: Long, songId: Long) {
|
||||
playlistDao.addSongToPlaylist(
|
||||
PlaylistSongEntity(playlistId = playlistId, songId = songId)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PlaylistEntity?>(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,7 +210,7 @@ fun LibraryScreen(
|
|||
) {
|
||||
tabLabels.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = {
|
||||
if (index == 0) {
|
||||
viewModel.selectPlaylist(null)
|
||||
|
|
@ -216,6 +218,13 @@ fun LibraryScreen(
|
|||
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 = 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Long?>(null)
|
||||
|
||||
val allPlaylists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
|
||||
|
|
@ -94,12 +98,44 @@ 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<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) {
|
||||
playbackManager.playLocalFile(
|
||||
|
|
@ -176,7 +212,17 @@ class LibraryViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
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
|
||||
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<SavedSongEntity> = emptyList(),
|
||||
val shuffleIndex: Int = -1,
|
||||
val isImporting: Boolean = false,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue