From e99ed1dc4587beae0125324f7c31a3b581f12d82 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:44:36 +0000 Subject: [PATCH] song added sort, robust scroll reset, restyled search and filter controls --- .../9.json | 830 ++++++++++++++++++ .../com/subtracks/data/db/LibraryDao.kt | 32 + .../com/subtracks/data/db/Migrations.kt | 9 +- .../subtracks/data/db/SubtracksDatabase.kt | 2 +- .../kotlin/com/subtracks/data/model/Models.kt | 1 + .../subtracks/data/prefs/UserPreferences.kt | 2 +- .../subtracks/data/repo/LibraryRepository.kt | 8 + .../data/source/subsonic/SubsonicXml.kt | 1 + .../com/subtracks/data/sync/SyncService.kt | 4 +- .../subtracks/ui/components/ViewportFill.kt | 35 + .../com/subtracks/ui/library/AlbumsContent.kt | 4 +- .../subtracks/ui/library/ArtistsContent.kt | 4 +- .../com/subtracks/ui/library/LibraryScreen.kt | 165 ++-- .../subtracks/ui/library/PlaylistsContent.kt | 4 +- .../com/subtracks/ui/library/SongsContent.kt | 4 +- .../com/subtracks/data/db/LibraryDaoTest.kt | 24 + .../com/subtracks/ui/LibraryOptionsTest.kt | 2 +- 17 files changed, 1043 insertions(+), 88 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/9.json diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/9.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/9.json new file mode 100644 index 00000000..3daa13c2 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/9.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 96f2edad..621152eb 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -454,6 +454,38 @@ interface LibraryDao { search: String, ): PagingSource + @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 + + @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 + @Query(PLAYLIST_SONGS_SQL) fun playlistSongs( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt index 2db04fcb..d89c242e 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -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 = - 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) diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index 7c6f562c..7b6bdcef 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource ShuffleOrder::class, ArtworkSeed::class, ], - version = 8, + version = 9, exportSchema = true, ) @ColumnTypeConverters(QueueKindConverter::class) diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 4e56f35a..6bea907c 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -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( diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index e3bdf7ee..cad8c80b 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 50fa4240..4834bc9e 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -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) + } + } } } diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index 7aa980a5..b654743e 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -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( diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt index 3f0ce0bb..a8e2cd96 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -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(sourceId, id, name, albumCount, starr private fun Album.values() = listOf(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount, frequentRank, recentRank) -private fun Song.values() = listOf(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre) +private fun Song.values() = + listOf(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created) private fun Playlist.values() = listOf(sourceId, id, name, comment, coverArt, songCount, created, changed, duration) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt b/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt index 055183e7..8bcac2b9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt @@ -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 diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index 78ff1163..0b5f4c07 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index ff19e3dd..0b906e49 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 9f905f44..3c2c1dbb 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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(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), ) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index fb276179..6f24169e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index 8f4c1e65..3a3314af 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -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, diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 9ab331d1..6cd81431 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -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, ) } diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt index dede623c..5d8c26a0 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt @@ -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) }