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")
|
@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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue