song added sort, robust scroll reset, restyled search and filter controls

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 9326429e17
commit e99ed1dc45
17 changed files with 1043 additions and 88 deletions
@@ -454,6 +454,38 @@ interface LibraryDao {
search: String,
): PagingSource<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"ORDER BY songs.created DESC, songs.title COLLATE NOCASE, songs.id",
)
fun songsByAdded(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"ORDER BY songs.created ASC, songs.title COLLATE NOCASE, songs.id",
)
fun songsByAddedReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, SongListItem>
@Query(PLAYLIST_SONGS_SQL)
fun playlistSongs(
sourceId: Long,
@@ -79,5 +79,12 @@ val MIGRATION_7_8 =
}
}
val MIGRATION_8_9 =
object : Migration(8, 9) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `created` INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 8,
version = 9,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -160,6 +160,7 @@ data class Song(
val disc: Long?,
val starred: Long?,
val genre: String?,
@ColumnInfo(defaultValue = "0") val created: Long = 0,
)
data class SongListItem(
@@ -16,7 +16,7 @@ enum class ArtistSort { Name, AlbumCount, Starred }
enum class PlaylistSort { Name, Added, Updated }
enum class SongSort { Album, Title, Artist, Starred }
enum class SongSort { Album, Title, Artist, Starred, Added }
enum class LibraryListTab(
val key: String,
@@ -183,6 +183,14 @@ class LibraryRepository(
dao.songsByStarred(sourceId, starred.ordinal, search)
}
}
SongSort.Added -> {
if (descending) {
dao.songsByAddedReversed(sourceId, starred.ordinal, search)
} else {
dao.songsByAdded(sourceId, starred.ordinal, search)
}
}
}
}
@@ -77,6 +77,7 @@ object SubsonicXml {
disc = element.longAttr("discNumber"),
starred = element.dateAttr("starred"),
genre = element.attr("genre").ifEmpty { null },
created = element.dateAttr("created") ?: 0L,
)
fun playlists(
@@ -117,6 +117,7 @@ private val songColumns =
"disc",
"starred",
"genre",
"created",
)
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
@@ -130,7 +131,8 @@ private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starr
private fun Album.values() =
listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount, frequentRank, recentRank)
private fun Song.values() = listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre)
private fun Song.values() =
listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
@@ -3,14 +3,49 @@ package com.subtracks.ui.components
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.paging.LoadState
private const val FILL_EPSILON_PX = 1
/**
* Scrolls back to the top once a new filter/sort has actually loaded.
*
* Scrolling immediately after [resetKey] changes does not stick: the lazy layout keeps the
* previously visible item in place by key while the paged list reloads, which snaps the scroll
* back. Waiting for the refresh triggered by the change and resetting afterwards avoids that.
*/
@Composable
fun ResetScrollOnChange(
resetKey: Any?,
refreshState: () -> LoadState,
onReset: suspend () -> Unit,
) {
var pending by remember { mutableStateOf(false) }
LaunchedEffect(resetKey) { pending = true }
LaunchedEffect(Unit) {
var refreshes = 0
var handled = 0
snapshotFlow(refreshState).collect { state ->
if (state is LoadState.Loading) {
refreshes++
} else if (pending && refreshes > handled) {
onReset()
handled = refreshes
pending = false
}
}
}
}
@Composable
fun rememberViewportFill(state: LazyListState): Dp {
val density = LocalDensity.current
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
@@ -29,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -60,7 +60,7 @@ fun AlbumsContent(
else -> {
val gridState = rememberLazyGridState()
LaunchedEffect(resetKey) { gridState.scrollToItem(0) }
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
val fill = rememberViewportFill(gridState)
LazyVerticalGrid(
state = gridState,
@@ -13,7 +13,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -29,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -58,7 +58,7 @@ fun ArtistsContent(
else -> {
val listState = rememberLazyListState()
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
val fill = rememberViewportFill(listState)
LazyColumn(
state = listState,
@@ -1,5 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -32,6 +33,7 @@ import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.FilterAltOff
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Remove
@@ -46,20 +48,17 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -153,6 +152,7 @@ fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
SortOption(SongSort.Title.name, "Title"),
SortOption(SongSort.Artist.name, "Artist"),
SortOption(SongSort.Starred.name, "Starred"),
SortOption(SongSort.Added.name, "Added"),
)
}
}
@@ -327,56 +327,54 @@ fun LibraryScreen(
state = pagerState,
modifier = Modifier.fillMaxWidth().weight(1f),
) { page ->
key(resetKey) {
when (LibraryTab.entries[page]) {
LibraryTab.Albums -> {
AlbumsContent(
albums,
coverArt,
listBottomInset,
onAlbumClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
when (LibraryTab.entries[page]) {
LibraryTab.Albums -> {
AlbumsContent(
albums,
coverArt,
listBottomInset,
onAlbumClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Artists -> {
ArtistsContent(
artists,
coverArt,
listBottomInset,
onArtistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Artists -> {
ArtistsContent(
artists,
coverArt,
listBottomInset,
onArtistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Songs -> {
SongsContent(
songs,
coverArt,
listBottomInset,
onSongClick,
playingSongId,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Songs -> {
SongsContent(
songs,
coverArt,
listBottomInset,
onSongClick,
playingSongId,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Playlists -> {
PlaylistsContent(
playlists,
coverArt,
listBottomInset,
onPlaylistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Playlists -> {
PlaylistsContent(
playlists,
coverArt,
listBottomInset,
onPlaylistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
}
}
@@ -456,12 +454,22 @@ private fun ListOptionsSheet(
onSearch: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
ListItem(
headlineContent = { Text("Search this list") },
leadingContent = { Icon(Icons.Rounded.Search, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClick = onSearch),
)
Surface(
onClick = onSearch,
shape = RoundedCornerShape(4.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Search this list", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
HorizontalDivider()
Row(
@@ -488,18 +496,30 @@ private fun ListOptionsSheet(
sortOptions.forEach { option ->
FilterChip(
selected = listQuery.sort == option.value,
onClick = { onSortChange(option.value) },
onClick = {
if (listQuery.sort == option.value) onToggleSortDirection() else onSortChange(option.value)
},
label = { Text(option.label) },
)
}
}
if (starredSupported) {
Text(
text = "Filters",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 16.dp),
) {
Text(
text = "Filters",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
if (listQuery.starred != StarredFilter.Any) {
IconButton(onClick = onClearFilters) {
Icon(Icons.Rounded.FilterAltOff, contentDescription = "Clear filters")
}
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
@@ -535,9 +555,6 @@ private fun ListOptionsSheet(
}
},
)
if (listQuery.starred != StarredFilter.Any) {
TextButton(onClick = onClearFilters) { Text("Clear filters") }
}
}
}
}
@@ -555,11 +572,11 @@ private fun SearchField(
modifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) { focusRequester.requestFocus() }
TextField(
OutlinedTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
placeholder = { Text("Search") },
label = { Text("Search") },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = {
IconButton(onClick = onClose) {
@@ -567,11 +584,9 @@ private fun SearchField(
}
},
colors =
TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.background,
unfocusedContainerColor = MaterialTheme.colorScheme.background,
),
modifier = modifier.focusRequester(focusRequester),
)
@@ -13,7 +13,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -29,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
@Composable
@@ -57,7 +57,7 @@ fun PlaylistsContent(
else -> {
val listState = rememberLazyListState()
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
val fill = rememberViewportFill(listState)
LazyColumn(
state = listState,
@@ -21,7 +21,6 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -40,6 +39,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
@Composable
@@ -69,7 +69,7 @@ fun SongsContent(
else -> {
val listState = rememberLazyListState()
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
val fill = rememberViewportFill(listState)
LazyColumn(
state = listState,
@@ -111,6 +111,28 @@ class LibraryDaoTest {
assertEquals(listOf("Beta"), dao.albumsByName(sourceId, starredFilter = 2, search = "").page().map { it.name })
}
@Test
fun songAddedSortOrdersByCreated() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Zebra", starred = null, created = 100),
song(sourceId, "s2", "Apple", starred = null, created = 200),
),
)
assertEquals(
listOf("Apple", "Zebra"),
dao.songsByAdded(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
)
assertEquals(
listOf("Zebra", "Apple"),
dao.songsByAddedReversed(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
)
}
private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
@@ -149,6 +171,7 @@ class LibraryDaoTest {
id: String,
title: String,
starred: Long?,
created: Long = 0,
) = Song(
sourceId = sourceId,
id = id,
@@ -162,5 +185,6 @@ class LibraryDaoTest {
disc = 1,
starred = starred,
genre = null,
created = created,
)
}
@@ -95,7 +95,7 @@ class LibraryOptionsTest {
composeRule.waitForIdle()
assertEquals(StarredFilter.Starred, query.starred)
composeRule.onNodeWithText("Clear filters").performClick()
composeRule.onNodeWithContentDescription("Clear filters").performClick()
composeRule.waitForIdle()
assertEquals(StarredFilter.Any, query.starred)
}