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
+830
View File
@@ -0,0 +1,830 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "7cac4ec967b6e0b53bab01a092dbc0f9",
"entities": [
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "subsonic_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useTokenAuth",
"columnName": "useTokenAuth",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId"
]
},
"indices": [
{
"name": "index_subsonic_sources_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_artists_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL, `albumArtist` TEXT, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `frequentRank` INTEGER, `recentRank` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumArtist",
"columnName": "albumArtist",
"affinity": "TEXT"
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER"
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "frequentRank",
"columnName": "frequentRank",
"affinity": "INTEGER"
},
{
"fieldPath": "recentRank",
"columnName": "recentRank",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_albums_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
},
{
"name": "index_albums_sourceId_artistId",
"unique": false,
"columnNames": [
"sourceId",
"artistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT"
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "changed",
"columnName": "changed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_playlists_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlist_songs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"playlistId",
"position"
]
},
"indices": [
{
"name": "index_playlist_songs_sourceId_playlistId",
"unique": false,
"columnNames": [
"sourceId",
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)"
},
{
"name": "index_playlist_songs_sourceId_songId",
"unique": false,
"columnNames": [
"sourceId",
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "songs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `genre` TEXT, `created` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT"
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER"
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER"
},
{
"fieldPath": "disc",
"columnName": "disc",
"affinity": "INTEGER"
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_songs_sourceId_albumId",
"unique": false,
"columnNames": [
"sourceId",
"albumId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)"
},
{
"name": "index_songs_sourceId_artistId",
"unique": false,
"columnNames": [
"sourceId",
"artistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "search_index",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`sourceId` UNINDEXED, `type` UNINDEXED, `itemId` UNINDEXED, `title`, tokenize=`trigram`)",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowid"
]
},
"ftsVersion": "FTS5",
"ftsOptions": {
"tokenizer": "trigram",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [
"sourceId",
"type",
"itemId"
],
"prefixSizes": [],
"preferredOrder": "ASC",
"contentRowId": "",
"columnSize": true,
"detail": "FULL"
},
"contentSyncTriggers": []
},
{
"tableName": "queue_entries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refId",
"columnName": "refId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rangeStart",
"columnName": "rangeStart",
"affinity": "INTEGER"
},
{
"fieldPath": "rangeEnd",
"columnName": "rangeEnd",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_queue_entries_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
},
{
"name": "index_queue_entries_position",
"unique": false,
"columnNames": [
"position"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playback_cursor",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "queuePosition",
"columnName": "queuePosition",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "shuffleEnabled",
"columnName": "shuffleEnabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "repeatMode",
"columnName": "repeatMode",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "shuffleSeed",
"columnName": "shuffleSeed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "positionMs",
"columnName": "positionMs",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "shuffle_order",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER NOT NULL, `flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))",
"fields": [
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "flatPosition",
"columnName": "flatPosition",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sequence"
]
}
},
{
"tableName": "artwork_seeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, PRIMARY KEY(`cacheKey`))",
"fields": [
{
"fieldPath": "cacheKey",
"columnName": "cacheKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "primary",
"columnName": "primary",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "secondary",
"columnName": "secondary",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"cacheKey"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7cac4ec967b6e0b53bab01a092dbc0f9')"
]
}
}
@@ -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)
}