From a2d42f7957bb6acc729773df28fa563b30e480d9 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:33:31 +0000 Subject: [PATCH] rework the library lists: inline filter, playlist updated, starred sort, clearer controls --- .../8.json | 823 ++++++++++++++++++ .../com/subtracks/data/db/LibraryDao.kt | 215 ++++- .../com/subtracks/data/db/Migrations.kt | 10 +- .../subtracks/data/db/SubtracksDatabase.kt | 2 +- .../kotlin/com/subtracks/data/model/Models.kt | 1 + .../subtracks/data/prefs/UserPreferences.kt | 8 +- .../subtracks/data/repo/LibraryRepository.kt | 116 ++- .../data/source/subsonic/SubsonicXml.kt | 1 + .../com/subtracks/data/sync/SyncService.kt | 4 +- .../main/kotlin/com/subtracks/di/AppModule.kt | 2 - .../subtracks/playback/PlaybackController.kt | 5 - .../main/kotlin/com/subtracks/ui/AppRoot.kt | 11 - .../com/subtracks/ui/library/AlbumsContent.kt | 3 + .../subtracks/ui/library/ArtistsContent.kt | 3 + .../com/subtracks/ui/library/LibraryScreen.kt | 150 +++- .../subtracks/ui/library/LibraryViewModel.kt | 38 +- .../subtracks/ui/library/PlaylistsContent.kt | 3 + .../com/subtracks/ui/library/SearchScreen.kt | 189 ---- .../subtracks/ui/library/SearchViewModel.kt | 53 -- .../com/subtracks/ui/library/SongsContent.kt | 3 + .../com/subtracks/data/db/LibraryDaoTest.kt | 56 +- .../data/prefs/UserPreferencesTest.kt | 33 + .../subtracks/data/sync/SyncServiceTest.kt | 12 +- ...est.kt => LibraryOptionsScreenshotTest.kt} | 33 +- .../ui/LibrarySearchScreenshotTest.kt | 94 ++ 25 files changed, 1460 insertions(+), 408 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/8.json delete mode 100644 app/src/main/kotlin/com/subtracks/ui/library/SearchScreen.kt delete mode 100644 app/src/main/kotlin/com/subtracks/ui/library/SearchViewModel.kt create mode 100644 app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt rename app/src/test/kotlin/com/subtracks/ui/{LibraryListsScreenshotTest.kt => LibraryOptionsScreenshotTest.kt} (74%) create mode 100644 app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/8.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/8.json new file mode 100644 index 00000000..c0bebf02 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/8.json @@ -0,0 +1,823 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "b81899571e6c347fcbea168630953c1b", + "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, 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" + } + ], + "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, 'b81899571e6c347fcbea168630953c1b')" + ] + } +} \ 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 1df8415b..7aba6c3c 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -90,179 +90,354 @@ interface LibraryDao { ) @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY name COLLATE NOCASE", ) fun albumsByName( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY name COLLATE NOCASE DESC", ) fun albumsByNameReversed( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE", ) fun albumsByArtist( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC", ) fun albumsByArtistReversed( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY year DESC, name COLLATE NOCASE", ) fun albumsByYear( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY year ASC, name COLLATE NOCASE", ) fun albumsByYearReversed( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY created DESC, name COLLATE NOCASE", ) fun albumsByRecentlyAdded( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY created ASC, name COLLATE NOCASE", ) fun albumsByRecentlyAddedReversed( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + + "ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE", + ) + fun albumsByStarred( + sourceId: Long, + starredOnly: Boolean, + search: String, + ): PagingSource + + @Query( + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + + "ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE", + ) + fun albumsByStarredReversed( + sourceId: Long, + starredOnly: Boolean, + search: String, + ): PagingSource + + @Query( + "SELECT * FROM artists WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY name COLLATE NOCASE", ) fun artistsByName( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM artists WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY name COLLATE NOCASE DESC", ) fun artistsByNameReversed( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM artists WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY albumCount DESC, name COLLATE NOCASE", ) fun artistsByAlbumCount( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "SELECT * FROM artists WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY albumCount ASC, name COLLATE NOCASE", ) fun artistsByAlbumCountReversed( sourceId: Long, starredOnly: Boolean, + search: String, ): PagingSource - @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") - fun playlistsByName(sourceId: Long): PagingSource + @Query( + "SELECT * FROM artists WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + + "ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE", + ) + fun artistsByStarred( + sourceId: Long, + starredOnly: Boolean, + search: String, + ): PagingSource - @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE DESC") - fun playlistsByNameReversed(sourceId: Long): PagingSource + @Query( + "SELECT * FROM artists WHERE sourceId = :sourceId " + + "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + + "ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE", + ) + fun artistsByStarredReversed( + sourceId: Long, + starredOnly: Boolean, + search: String, + ): PagingSource - @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE") - fun playlistsByRecentlyAdded(sourceId: Long): PagingSource + @Query( + "SELECT * FROM playlists WHERE sourceId = :sourceId " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + + "ORDER BY name COLLATE NOCASE", + ) + fun playlistsByName( + sourceId: Long, + search: String, + ): PagingSource - @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created ASC, name COLLATE NOCASE") - fun playlistsByRecentlyAddedReversed(sourceId: Long): PagingSource + @Query( + "SELECT * FROM playlists WHERE sourceId = :sourceId " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + + "ORDER BY name COLLATE NOCASE DESC", + ) + fun playlistsByNameReversed( + sourceId: Long, + search: String, + ): PagingSource + + @Query( + "SELECT * FROM playlists WHERE sourceId = :sourceId " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + + "ORDER BY created DESC, name COLLATE NOCASE", + ) + fun playlistsByAdded( + sourceId: Long, + search: String, + ): PagingSource + + @Query( + "SELECT * FROM playlists WHERE sourceId = :sourceId " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + + "ORDER BY created ASC, name COLLATE NOCASE", + ) + fun playlistsByAddedReversed( + sourceId: Long, + search: String, + ): PagingSource + + @Query( + "SELECT * FROM playlists WHERE sourceId = :sourceId " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + + "ORDER BY changed DESC, name COLLATE NOCASE", + ) + fun playlistsByUpdated( + sourceId: Long, + search: String, + ): PagingSource + + @Query( + "SELECT * FROM playlists WHERE sourceId = :sourceId " + + "AND (:search = '' OR name LIKE '%' || :search || '%') " + + "ORDER BY changed ASC, name COLLATE NOCASE", + ) + fun playlistsByUpdatedReversed( + sourceId: Long, + 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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " + "songs.title COLLATE NOCASE, songs.id", ) fun songs( sourceId: Long, starredOnly: Boolean, + 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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.title COLLATE NOCASE, songs.id", ) fun songsByTitle( sourceId: Long, starredOnly: Boolean, + 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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.title COLLATE NOCASE DESC, songs.id", ) fun songsByTitleReversed( sourceId: Long, starredOnly: Boolean, + 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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id", ) fun songsByArtist( sourceId: Long, starredOnly: Boolean, + 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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id", ) fun songsByArtistReversed( sourceId: Long, starredOnly: Boolean, + 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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + + "ORDER BY songs.starred IS NULL, songs.starred DESC, songs.title COLLATE NOCASE, songs.id", + ) + fun songsByStarred( + sourceId: Long, + starredOnly: Boolean, + 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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + + "ORDER BY songs.starred IS NULL, songs.starred ASC, songs.title COLLATE NOCASE, songs.id", + ) + fun songsByStarredReversed( + sourceId: Long, + starredOnly: Boolean, + search: String, ): PagingSource @Query(PLAYLIST_SONGS_SQL) 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 ff984bd8..2db04fcb 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -72,4 +72,12 @@ val MIGRATION_6_7 = } } -val MIGRATIONS: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7) +val MIGRATION_7_8 = + object : Migration(7, 8) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `changed` 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) 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 d9a0c80b..7c6f562c 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 = 7, + version = 8, 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 d9867216..4e56f35a 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -110,6 +110,7 @@ data class Playlist( val coverArt: String?, val songCount: Long, val created: Long, + @ColumnInfo(defaultValue = "0") val changed: Long = 0, @ColumnInfo(defaultValue = "0") val duration: Long = 0, ) 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 2edf7aa6..4a400784 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -10,13 +10,13 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -enum class AlbumSort { Name, Artist, Year, RecentlyAdded } +enum class AlbumSort { Name, Artist, Year, Added, Starred } -enum class ArtistSort { Name, AlbumCount } +enum class ArtistSort { Name, AlbumCount, Starred } -enum class PlaylistSort { Name, RecentlyAdded } +enum class PlaylistSort { Name, Added, Updated } -enum class SongSort { Album, Title, Artist } +enum class SongSort { Album, Title, Artist, Starred } 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 a28e431f..6db7c363 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -16,14 +16,6 @@ import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.SongSort import kotlinx.coroutines.flow.Flow -data class SearchHit( - val type: String, - val itemId: String, - val title: String, -) { - val isSong: Boolean get() = type == "song" -} - class LibraryRepository( private val db: SubtracksDatabase, private val sourceRepository: SourceRepository, @@ -35,27 +27,48 @@ class LibraryRepository( sort: AlbumSort, descending: Boolean, starredOnly: Boolean, + search: String, ): Flow> = pager(40) { val dao = db.libraryDao() when (sort) { AlbumSort.Name -> { - if (descending) dao.albumsByNameReversed(sourceId, starredOnly) else dao.albumsByName(sourceId, starredOnly) + if (descending) { + dao.albumsByNameReversed(sourceId, starredOnly, search) + } else { + dao.albumsByName(sourceId, starredOnly, search) + } } AlbumSort.Artist -> { - if (descending) dao.albumsByArtistReversed(sourceId, starredOnly) else dao.albumsByArtist(sourceId, starredOnly) + if (descending) { + dao.albumsByArtistReversed(sourceId, starredOnly, search) + } else { + dao.albumsByArtist(sourceId, starredOnly, search) + } } AlbumSort.Year -> { - if (descending) dao.albumsByYearReversed(sourceId, starredOnly) else dao.albumsByYear(sourceId, starredOnly) + if (descending) { + dao.albumsByYearReversed(sourceId, starredOnly, search) + } else { + dao.albumsByYear(sourceId, starredOnly, search) + } } - AlbumSort.RecentlyAdded -> { + AlbumSort.Added -> { if (descending) { - dao.albumsByRecentlyAddedReversed(sourceId, starredOnly) + dao.albumsByRecentlyAddedReversed(sourceId, starredOnly, search) } else { - dao.albumsByRecentlyAdded(sourceId, starredOnly) + dao.albumsByRecentlyAdded(sourceId, starredOnly, search) + } + } + + AlbumSort.Starred -> { + if (descending) { + dao.albumsByStarredReversed(sourceId, starredOnly, search) + } else { + dao.albumsByStarred(sourceId, starredOnly, search) } } } @@ -66,19 +79,32 @@ class LibraryRepository( sort: ArtistSort, descending: Boolean, starredOnly: Boolean, + search: String, ): Flow> = pager(60) { val dao = db.libraryDao() when (sort) { ArtistSort.Name -> { - if (descending) dao.artistsByNameReversed(sourceId, starredOnly) else dao.artistsByName(sourceId, starredOnly) + if (descending) { + dao.artistsByNameReversed(sourceId, starredOnly, search) + } else { + dao.artistsByName(sourceId, starredOnly, search) + } } ArtistSort.AlbumCount -> { if (descending) { - dao.artistsByAlbumCountReversed(sourceId, starredOnly) + dao.artistsByAlbumCountReversed(sourceId, starredOnly, search) } else { - dao.artistsByAlbumCount(sourceId, starredOnly) + dao.artistsByAlbumCount(sourceId, starredOnly, search) + } + } + + ArtistSort.Starred -> { + if (descending) { + dao.artistsByStarredReversed(sourceId, starredOnly, search) + } else { + dao.artistsByStarred(sourceId, starredOnly, search) } } } @@ -88,16 +114,33 @@ class LibraryRepository( sourceId: Long, sort: PlaylistSort, descending: Boolean, + search: String, ): Flow> = pager(40) { val dao = db.libraryDao() when (sort) { PlaylistSort.Name -> { - if (descending) dao.playlistsByNameReversed(sourceId) else dao.playlistsByName(sourceId) + if (descending) { + dao.playlistsByNameReversed(sourceId, search) + } else { + dao.playlistsByName(sourceId, search) + } } - PlaylistSort.RecentlyAdded -> { - if (descending) dao.playlistsByRecentlyAddedReversed(sourceId) else dao.playlistsByRecentlyAdded(sourceId) + PlaylistSort.Added -> { + if (descending) { + dao.playlistsByAddedReversed(sourceId, search) + } else { + dao.playlistsByAdded(sourceId, search) + } + } + + PlaylistSort.Updated -> { + if (descending) { + dao.playlistsByUpdatedReversed(sourceId, search) + } else { + dao.playlistsByUpdated(sourceId, search) + } } } } @@ -107,34 +150,41 @@ class LibraryRepository( sort: SongSort, descending: Boolean, starredOnly: Boolean, + search: String, ): Flow> = pager(60) { val dao = db.libraryDao() when (sort) { SongSort.Album -> { - dao.songs(sourceId, starredOnly) + dao.songs(sourceId, starredOnly, search) } SongSort.Title -> { - if (descending) dao.songsByTitleReversed(sourceId, starredOnly) else dao.songsByTitle(sourceId, starredOnly) + if (descending) { + dao.songsByTitleReversed(sourceId, starredOnly, search) + } else { + dao.songsByTitle(sourceId, starredOnly, search) + } } SongSort.Artist -> { - if (descending) dao.songsByArtistReversed(sourceId, starredOnly) else dao.songsByArtist(sourceId, starredOnly) + if (descending) { + dao.songsByArtistReversed(sourceId, starredOnly, search) + } else { + dao.songsByArtist(sourceId, starredOnly, search) + } + } + + SongSort.Starred -> { + if (descending) { + dao.songsByStarredReversed(sourceId, starredOnly, search) + } else { + dao.songsByStarred(sourceId, starredOnly, search) + } } } } - suspend fun search( - sourceId: Long, - query: String, - limit: Int = 50, - ): List = - db - .searchDao() - .search(sourceId.toString(), query, limit) - .map { SearchHit(it.type, it.itemId, it.title) } - fun album( sourceId: Long, albumId: String, 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 58db9786..04909a5c 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 @@ -94,6 +94,7 @@ object SubsonicXml { coverArt = element.attr("coverArt").ifEmpty { null }, songCount = element.longAttr("songCount") ?: 0L, created = element.dateAttr("created") ?: 0L, + changed = element.dateAttr("changed") ?: 0L, duration = element.longAttr("duration") ?: 0L, ) 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 4fd48d06..3f0ce0bb 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -119,7 +119,7 @@ private val songColumns = "genre", ) -private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "duration") +private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration") private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position") @@ -132,7 +132,7 @@ private fun Album.values() = private fun Song.values() = listOf(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre) -private fun Playlist.values() = listOf(sourceId, id, name, comment, coverArt, songCount, created, duration) +private fun Playlist.values() = listOf(sourceId, id, name, comment, coverArt, songCount, created, changed, duration) private fun PlaylistSong.values() = listOf(sourceId, playlistId, songId, position) diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 0d1787b6..6e077cb6 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -17,7 +17,6 @@ import com.subtracks.ui.library.AlbumDetailViewModel import com.subtracks.ui.library.ArtistDetailViewModel import com.subtracks.ui.library.LibraryViewModel import com.subtracks.ui.library.PlaylistDetailViewModel -import com.subtracks.ui.library.SearchViewModel import com.subtracks.ui.playback.QueueViewModel import com.subtracks.ui.settings.AddSourceViewModel import com.subtracks.ui.settings.SettingsViewModel @@ -41,7 +40,6 @@ fun appModule( single { PlaybackController(get(), get(), get()) } viewModel { RootViewModel(get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get()) } - viewModel { SearchViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index f875f708..8f36d2d9 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -192,11 +192,6 @@ class PlaybackController( startPosition: Long, ) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition) - fun playSong( - sourceId: Long, - songId: String, - ) = play(listOf(queueRepository.songEntry(sourceId, songId)), 0) - fun shuffleAlbum( sourceId: Long, albumId: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index f692ca28..b1251001 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -50,7 +50,6 @@ import com.subtracks.ui.library.AlbumDetailRoute import com.subtracks.ui.library.ArtistDetailRoute import com.subtracks.ui.library.LibraryRoute import com.subtracks.ui.library.PlaylistDetailRoute -import com.subtracks.ui.library.SearchRoute import com.subtracks.ui.playback.MiniPlayer import com.subtracks.ui.playback.NowPlayingRoute import com.subtracks.ui.playback.QueueRoute @@ -82,7 +81,6 @@ private const val FLING_VELOCITY = 1000f private object Routes { const val LIBRARY = "library" - const val SEARCH = "search" const val SETTINGS = "settings" const val ADD_SERVER = "add-server" const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" @@ -186,18 +184,9 @@ private fun MainNavigation() { onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) }, onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, - onSearch = { navController.navigate(Routes.SEARCH) }, bottomInset = if (playerVisible) 0.dp else navBarInset, ) } - composable(Routes.SEARCH) { - SearchRoute( - onBack = { navController.popBackStack() }, - onAlbumClick = { navController.navigate(Routes.album(it)) }, - onArtistClick = { navController.navigate(Routes.artist(it)) }, - onPlaylistClick = { navController.navigate(Routes.playlist(it)) }, - ) - } composable(Routes.SETTINGS) { SettingsRoute( onAddServer = { navController.navigate(Routes.ADD_SERVER) }, 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 c8cc9a24..e1808b1d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -14,6 +14,7 @@ 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 @@ -38,6 +39,7 @@ fun AlbumsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onAlbumClick: (Album) -> Unit, + resetKey: Any? = null, modifier: Modifier = Modifier, ) { when { @@ -51,6 +53,7 @@ fun AlbumsContent( else -> { val gridState = rememberLazyGridState() + LaunchedEffect(resetKey) { 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 21253e92..4cadc3cd 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -13,6 +13,7 @@ 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 @@ -36,6 +37,7 @@ fun ArtistsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onArtistClick: (Artist) -> Unit, + resetKey: Any? = null, modifier: Modifier = Modifier, ) { when { @@ -49,6 +51,7 @@ fun ArtistsContent( else -> { val listState = rememberLazyListState() + LaunchedEffect(resetKey) { 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 ba84f59a..422c6e27 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -26,13 +26,15 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay +import androidx.compose.material.icons.filled.Sort 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.MusicNote import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material.icons.rounded.Sort -import androidx.compose.material.icons.rounded.SwapVert import androidx.compose.material.icons.rounded.Sync import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -47,6 +49,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet 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 @@ -62,6 +66,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp @@ -115,7 +121,8 @@ fun sortOptionsFor(tab: LibraryTab): List = SortOption(AlbumSort.Name.name, "Name"), SortOption(AlbumSort.Artist.name, "Artist"), SortOption(AlbumSort.Year.name, "Year"), - SortOption(AlbumSort.RecentlyAdded.name, "Recently added"), + SortOption(AlbumSort.Added.name, "Added"), + SortOption(AlbumSort.Starred.name, "Starred"), ) } @@ -123,13 +130,15 @@ fun sortOptionsFor(tab: LibraryTab): List = listOf( SortOption(ArtistSort.Name.name, "Name"), SortOption(ArtistSort.AlbumCount.name, "Albums"), + SortOption(ArtistSort.Starred.name, "Starred"), ) } LibraryTab.Playlists -> { listOf( SortOption(PlaylistSort.Name.name, "Name"), - SortOption(PlaylistSort.RecentlyAdded.name, "Recently added"), + SortOption(PlaylistSort.Added.name, "Added"), + SortOption(PlaylistSort.Updated.name, "Updated"), ) } @@ -138,6 +147,7 @@ fun sortOptionsFor(tab: LibraryTab): List = SortOption(SongSort.Album.name, "Album"), SortOption(SongSort.Title.name, "Title"), SortOption(SongSort.Artist.name, "Artist"), + SortOption(SongSort.Starred.name, "Starred"), ) } } @@ -149,15 +159,15 @@ fun LibraryRoute( onArtistClick: (Artist) -> Unit, onPlaylistClick: (Playlist) -> Unit, onOpenSettings: () -> Unit, - onSearch: () -> Unit, bottomInset: Dp, viewModel: LibraryViewModel = koinViewModel(), ) { var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } val syncing by viewModel.syncing.collectAsStateWithLifecycle() val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle() - val listQuery by viewModel.listQuery(selectedTab.listTab()).collectAsStateWithLifecycle() val listTab = selectedTab.listTab() + val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle() + val search by viewModel.search(listTab).collectAsStateWithLifecycle() LibraryScreen( selectedTab = selectedTab, onTabSelected = { selectedTab = it }, @@ -182,7 +192,8 @@ fun LibraryRoute( onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) }, onToggleStarred = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = !listQuery.starredOnly)) }, onClearFilters = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = false)) }, - onSearch = onSearch, + search = search, + onSearchChange = { viewModel.setSearch(listTab, it) }, ) } @@ -212,7 +223,8 @@ fun LibraryScreen( onToggleSortDirection: () -> Unit = {}, onToggleStarred: () -> Unit = {}, onClearFilters: () -> Unit = {}, - onSearch: () -> Unit = {}, + search: String = "", + onSearchChange: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { val pagerState = @@ -221,6 +233,8 @@ fun LibraryScreen( pageCount = { LibraryTab.entries.size }, ) var showOptions by rememberSaveable { mutableStateOf(false) } + var searchActive by rememberSaveable { mutableStateOf(false) } + val searchFocus = remember { FocusRequester() } LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { page -> @@ -247,7 +261,8 @@ fun LibraryScreen( val titleFraction = scrollBehavior.state.collapsedFraction val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } - val listBottomInset = bottomInset + FAB_CLEARANCE + val listBottomInset = bottomInset + if (searchActive) 0.dp else FAB_CLEARANCE + val resetKey = listOf(listQuery.sort, listQuery.descending, listQuery.starredOnly, search) Box(modifier.fillMaxSize()) { Column( @@ -296,30 +311,65 @@ fun LibraryScreen( modifier = Modifier.fillMaxWidth().weight(1f), ) { page -> when (LibraryTab.entries[page]) { - LibraryTab.Albums -> AlbumsContent(albums, coverArt, listBottomInset, onAlbumClick) - LibraryTab.Artists -> ArtistsContent(artists, coverArt, listBottomInset, onArtistClick) - LibraryTab.Songs -> SongsContent(songs, coverArt, listBottomInset, onSongClick, playingSongId) - LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, listBottomInset, onPlaylistClick) + LibraryTab.Albums -> { + AlbumsContent(albums, coverArt, listBottomInset, onAlbumClick, resetKey = resetKey) + } + + LibraryTab.Artists -> { + ArtistsContent(artists, coverArt, listBottomInset, onArtistClick, resetKey = resetKey) + } + + LibraryTab.Songs -> { + SongsContent( + songs, + coverArt, + listBottomInset, + onSongClick, + playingSongId, + resetKey = resetKey, + ) + } + + LibraryTab.Playlists -> { + PlaylistsContent(playlists, coverArt, listBottomInset, onPlaylistClick, resetKey = resetKey) + } } } + + if (searchActive) { + SearchField( + value = search, + onValueChange = onSearchChange, + onClose = { + searchActive = false + onSearchChange("") + }, + focusRequester = searchFocus, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + ) + } } - FloatingActionButton( - onClick = { showOptions = true }, - modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), - ) { - Box { - Icon(Icons.Rounded.Sort, contentDescription = "List options") - if (listQuery.starredOnly) { - Box( - modifier = - Modifier - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp) - .size(8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - ) + if (!searchActive) { + FloatingActionButton( + onClick = { showOptions = true }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) { + Box { + Icon(Icons.Filled.Sort, contentDescription = "List options") + if (listQuery.starredOnly) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-4).dp) + .size(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + } } } } @@ -337,7 +387,7 @@ fun LibraryScreen( onClearFilters = onClearFilters, onSearch = { showOptions = false - onSearch() + searchActive = true }, ) } @@ -358,7 +408,7 @@ private fun ListOptionsSheet( ) { Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { ListItem( - headlineContent = { Text("Search your library") }, + headlineContent = { Text("Search this list") }, leadingContent = { Icon(Icons.Rounded.Search, contentDescription = null) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier.clickable(onClick = onSearch), @@ -375,7 +425,10 @@ private fun ListOptionsSheet( modifier = Modifier.weight(1f), ) IconButton(onClick = onToggleSortDirection) { - Icon(Icons.Rounded.SwapVert, contentDescription = "Reverse sort order") + Icon( + imageVector = if (listQuery.descending) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward, + contentDescription = if (listQuery.descending) "Sort descending" else "Sort ascending", + ) } } FlowRow( @@ -405,7 +458,7 @@ private fun ListOptionsSheet( FilterChip( selected = listQuery.starredOnly, onClick = onToggleStarred, - label = { Text("Starred only") }, + label = { Text("Starred") }, ) if (listQuery.starredOnly) { TextButton(onClick = onClearFilters) { Text("Clear") } @@ -417,6 +470,37 @@ private fun ListOptionsSheet( private val FAB_CLEARANCE = 80.dp +@Composable +private fun SearchField( + value: String, + onValueChange: (String) -> Unit, + onClose: () -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + TextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + placeholder = { Text("Search") }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + trailingIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Rounded.Close, contentDescription = "Close search") + } + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + modifier = modifier.focusRequester(focusRequester), + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun LibraryTabs( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index f8fcabd1..efe8f756 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -24,8 +24,10 @@ import com.subtracks.data.sync.SyncStatus import com.subtracks.playback.PlaybackController import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -47,12 +49,17 @@ class LibraryViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, ListQuery(tab.defaultSort)) } + private val searches: Map> = + LibraryListTab.entries.associateWith { MutableStateFlow("") } + val albums: Flow> = libraryRepository.activeSourceId .filterNotNull() .flatMapLatest { sourceId -> - listQueries.getValue(LibraryListTab.Albums).flatMapLatest { query -> - libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starredOnly) + combine(listQueries.getValue(LibraryListTab.Albums), searches.getValue(LibraryListTab.Albums)) { query, search -> + query to search + }.flatMapLatest { (query, search) -> + libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starredOnly, search) } }.cachedIn(viewModelScope) @@ -60,8 +67,10 @@ class LibraryViewModel( libraryRepository.activeSourceId .filterNotNull() .flatMapLatest { sourceId -> - listQueries.getValue(LibraryListTab.Artists).flatMapLatest { query -> - libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starredOnly) + combine(listQueries.getValue(LibraryListTab.Artists), searches.getValue(LibraryListTab.Artists)) { query, search -> + query to search + }.flatMapLatest { (query, search) -> + libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starredOnly, search) } }.cachedIn(viewModelScope) @@ -69,8 +78,10 @@ class LibraryViewModel( libraryRepository.activeSourceId .filterNotNull() .flatMapLatest { sourceId -> - listQueries.getValue(LibraryListTab.Playlists).flatMapLatest { query -> - libraryRepository.playlists(sourceId, query.playlistSort(), query.descending) + combine(listQueries.getValue(LibraryListTab.Playlists), searches.getValue(LibraryListTab.Playlists)) { query, search -> + query to search + }.flatMapLatest { (query, search) -> + libraryRepository.playlists(sourceId, query.playlistSort(), query.descending, search) } }.cachedIn(viewModelScope) @@ -78,8 +89,10 @@ class LibraryViewModel( libraryRepository.activeSourceId .filterNotNull() .flatMapLatest { sourceId -> - listQueries.getValue(LibraryListTab.Songs).flatMapLatest { query -> - libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starredOnly) + combine(listQueries.getValue(LibraryListTab.Songs), searches.getValue(LibraryListTab.Songs)) { query, search -> + query to search + }.flatMapLatest { (query, search) -> + libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starredOnly, search) } }.cachedIn(viewModelScope) @@ -102,6 +115,15 @@ class LibraryViewModel( viewModelScope.launch { userPreferences.setListQuery(tab, query) } } + fun search(tab: LibraryListTab): StateFlow = searches.getValue(tab) + + fun setSearch( + tab: LibraryListTab, + value: String, + ) { + searches.getValue(tab).value = value + } + fun coverArt( coverArt: String?, thumbnail: Boolean, 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 a95f68f6..9e83fba0 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -13,6 +13,7 @@ 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 @@ -35,6 +36,7 @@ fun PlaylistsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onPlaylistClick: (Playlist) -> Unit, + resetKey: Any? = null, modifier: Modifier = Modifier, ) { when { @@ -48,6 +50,7 @@ fun PlaylistsContent( else -> { val listState = rememberLazyListState() + LaunchedEffect(resetKey) { listState.scrollToItem(0) } val fill = rememberViewportFill(listState) LazyColumn( state = listState, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SearchScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/SearchScreen.kt deleted file mode 100644 index b6044720..00000000 --- a/app/src/main/kotlin/com/subtracks/ui/library/SearchScreen.kt +++ /dev/null @@ -1,189 +0,0 @@ -package com.subtracks.ui.library - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay -import androidx.compose.material.icons.rounded.Album -import androidx.compose.material.icons.rounded.MusicNote -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.Search -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.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.subtracks.data.repo.SearchHit -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun SearchRoute( - onBack: () -> Unit, - onAlbumClick: (String) -> Unit, - onArtistClick: (String) -> Unit, - onPlaylistClick: (String) -> Unit, - viewModel: SearchViewModel = koinViewModel(), -) { - val results by viewModel.results.collectAsStateWithLifecycle() - var query by rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.requestFocus() } - LaunchedEffect(query) { viewModel.onQueryChange(query) } - SearchScreen( - query = query, - results = results, - onQueryChange = { query = it }, - onBack = onBack, - onAlbumClick = onAlbumClick, - onArtistClick = onArtistClick, - onPlaylistClick = onPlaylistClick, - onSongClick = viewModel::playSong, - fieldModifier = Modifier.focusRequester(focusRequester), - ) -} - -@Composable -fun SearchScreen( - query: String, - results: List, - onQueryChange: (String) -> Unit, - onBack: () -> Unit, - onAlbumClick: (String) -> Unit, - onArtistClick: (String) -> Unit, - onPlaylistClick: (String) -> Unit, - onSongClick: (String) -> Unit, - fieldModifier: Modifier = Modifier, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .windowInsetsPadding(WindowInsets.statusBars), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), - ) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") - } - TextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - placeholder = { Text("Search your library") }, - colors = - TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - modifier = Modifier.weight(1f).then(fieldModifier), - ) - } - - LazyColumn(Modifier.fillMaxSize()) { - section("Songs", results.filter { it.type == "song" }, onSongClick) - section("Albums", results.filter { it.type == "album" }, onAlbumClick) - section("Artists", results.filter { it.type == "artist" }, onArtistClick) - section("Playlists", results.filter { it.type == "playlist" }, onPlaylistClick) - if (query.trim().length >= 3 && results.isEmpty()) { - item { - Text( - text = "No matches.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(24.dp), - ) - } - } - } - } -} - -private fun LazyListScope.section( - title: String, - hits: List, - onItemClick: (String) -> Unit, -) { - if (hits.isEmpty()) return - item(key = "header:$title") { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), - ) - } - items(hits, key = { "${it.type}:${it.itemId}" }) { hit -> - SearchRow(hit = hit, onClick = { onItemClick(hit.itemId) }) - } -} - -@Composable -private fun SearchRow( - hit: SearchHit, - onClick: () -> Unit, -) { - ListItem( - headlineContent = { - Text( - text = hit.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - leadingContent = { - Icon( - imageVector = iconFor(hit.type), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable(onClick = onClick), - ) -} - -private fun iconFor(type: String): ImageVector = - when (type) { - "song" -> Icons.Rounded.MusicNote - "album" -> Icons.Rounded.Album - "artist" -> Icons.Rounded.Person - "playlist" -> Icons.AutoMirrored.Rounded.PlaylistPlay - else -> Icons.Rounded.Search - } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SearchViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/SearchViewModel.kt deleted file mode 100644 index 471ac6ef..00000000 --- a/app/src/main/kotlin/com/subtracks/ui/library/SearchViewModel.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.subtracks.ui.library - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.subtracks.data.repo.LibraryRepository -import com.subtracks.data.repo.SearchHit -import com.subtracks.data.repo.SourceRepository -import com.subtracks.playback.PlaybackController -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -class SearchViewModel( - private val libraryRepository: LibraryRepository, - private val sourceRepository: SourceRepository, - private val playbackController: PlaybackController, -) : ViewModel() { - private val query = MutableStateFlow("") - - val results: StateFlow> = - query - .debounce(SEARCH_DEBOUNCE_MS) - .flatMapLatest { text -> - sourceRepository - .activeSourceId() - .filterNotNull() - .flatMapLatest { sourceId -> flow { emit(libraryRepository.search(sourceId, text)) } } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - fun onQueryChange(value: String) { - query.value = value - } - - fun playSong(songId: String) { - viewModelScope.launch { - val sourceId = sourceRepository.activeSourceIdOnce() ?: return@launch - playbackController.playSong(sourceId, songId) - } - } - - private companion object { - const val SEARCH_DEBOUNCE_MS = 200L - } -} 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 b5b3ccfe..aa516750 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -21,6 +21,7 @@ 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 @@ -47,6 +48,7 @@ fun SongsContent( bottomInset: Dp, onSongClick: (Int) -> Unit, playingSongId: String? = null, + resetKey: Any? = null, modifier: Modifier = Modifier, ) { when { @@ -60,6 +62,7 @@ fun SongsContent( else -> { val listState = rememberLazyListState() + LaunchedEffect(resetKey) { 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 8848ba8d..52e4dd70 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -48,10 +48,19 @@ class LibraryDaoTest { ), ) - assertEquals(listOf("Alpha", "Beta", "Gamma"), dao.albumsByName(sourceId, starredOnly = false).page().map { it.name }) - assertEquals(listOf("Gamma", "Beta", "Alpha"), dao.albumsByNameReversed(sourceId, starredOnly = false).page().map { it.name }) - assertEquals(listOf("Alpha", "Beta", "Gamma"), dao.albumsByYear(sourceId, starredOnly = false).page().map { it.name }) - assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredOnly = true).page().map { it.name }) + assertEquals( + listOf("Alpha", "Beta", "Gamma"), + dao.albumsByName(sourceId, starredOnly = false, search = "").page().map { it.name }, + ) + assertEquals( + listOf("Gamma", "Beta", "Alpha"), + dao.albumsByNameReversed(sourceId, starredOnly = false, search = "").page().map { it.name }, + ) + assertEquals( + listOf("Alpha", "Beta", "Gamma"), + dao.albumsByYear(sourceId, starredOnly = false, search = "").page().map { it.name }, + ) + assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredOnly = true, search = "").page().map { it.name }) } @Test @@ -66,9 +75,39 @@ class LibraryDaoTest { ), ) - assertEquals(listOf("Apple", "Zebra"), dao.songsByTitle(sourceId, starredOnly = false).page().map { it.song.title }) - assertEquals(listOf("Zebra", "Apple"), dao.songsByTitleReversed(sourceId, starredOnly = false).page().map { it.song.title }) - assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredOnly = true).page().map { it.song.title }) + assertEquals( + listOf("Apple", "Zebra"), + dao.songsByTitle(sourceId, starredOnly = false, search = "").page().map { it.song.title }, + ) + assertEquals( + listOf("Zebra", "Apple"), + dao.songsByTitleReversed(sourceId, starredOnly = false, search = "").page().map { it.song.title }, + ) + assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredOnly = true, search = "").page().map { it.song.title }) + } + + @Test + fun albumAddedAndStarredSortsAndSearch() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "Beta", year = 2000, starred = null, created = 300), + album(sourceId, "b", "Alpha", year = 2010, starred = 200, created = 100), + album(sourceId, "c", "Gamma", year = 1990, starred = 100, created = 200), + ), + ) + + assertEquals( + listOf("Beta", "Gamma", "Alpha"), + dao.albumsByRecentlyAdded(sourceId, starredOnly = false, search = "").page().map { it.name }, + ) + assertEquals( + listOf("Alpha", "Gamma", "Beta"), + dao.albumsByStarred(sourceId, starredOnly = false, search = "").page().map { it.name }, + ) + assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredOnly = false, search = "gam").page().map { it.name }) } private suspend fun source(): Long = @@ -87,13 +126,14 @@ class LibraryDaoTest { name: String, year: Long?, starred: Long?, + created: Long = 0, ) = Album( sourceId = sourceId, id = id, artistId = "ar-1", name = name, albumArtist = "Artist", - created = 0, + created = created, coverArt = null, genre = null, year = year, diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt new file mode 100644 index 00000000..9e0669f7 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -0,0 +1,33 @@ +package com.subtracks.data.prefs + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class UserPreferencesTest { + @Test + fun listQueryRoundTripsPerTab() = + runTest { + val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() } + val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val prefs = UserPreferences(store) + + assertEquals(ListQuery("Name"), prefs.listQuery(LibraryListTab.Albums).first()) + assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first()) + + prefs.setListQuery(LibraryListTab.Albums, ListQuery("Added", descending = true, starredOnly = true)) + + assertEquals(ListQuery("Added", descending = true, starredOnly = true), prefs.listQuery(LibraryListTab.Albums).first()) + assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first()) + + file.delete() + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index b5f11843..7808b9c9 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -60,7 +60,7 @@ class SyncServiceTest { 2, db .libraryDao() - .artistsByName(1, false) + .artistsByName(1, false, "") .allRows() .size, ) @@ -68,7 +68,7 @@ class SyncServiceTest { 2, db .libraryDao() - .songs(1, false) + .songs(1, false, "") .allRows() .size, ) @@ -90,7 +90,7 @@ class SyncServiceTest { listOf("a1"), db .libraryDao() - .artistsByName(1, false) + .artistsByName(1, false, "") .allRows() .map { it.id }, ) @@ -98,7 +98,7 @@ class SyncServiceTest { listOf("s1"), db .libraryDao() - .songs(1, false) + .songs(1, false, "") .allRows() .map { it.song.id }, ) @@ -200,7 +200,7 @@ class SyncServiceTest { "Old", db .libraryDao() - .artistsByName(1, false) + .artistsByName(1, false, "") .allRows() .single() .name, @@ -213,7 +213,7 @@ class SyncServiceTest { "New", db .libraryDao() - .artistsByName(1, false) + .artistsByName(1, false, "") .allRows() .single() .name, diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryListsScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt similarity index 74% rename from app/src/test/kotlin/com/subtracks/ui/LibraryListsScreenshotTest.kt rename to app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt index 62840bda..ceb8350a 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryListsScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick @@ -19,11 +18,9 @@ import com.subtracks.data.model.Artist import com.subtracks.data.model.Playlist import com.subtracks.data.model.SongListItem import com.subtracks.data.prefs.ListQuery -import com.subtracks.data.repo.SearchHit import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.LibraryScreen import com.subtracks.ui.library.LibraryTab -import com.subtracks.ui.library.SearchScreen import com.subtracks.ui.library.sortOptionsFor import com.subtracks.ui.theme.SubtracksTheme import kotlinx.coroutines.flow.flowOf @@ -36,7 +33,7 @@ import org.robolectric.annotation.GraphicsMode @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) -class LibraryListsScreenshotTest { +class LibraryOptionsScreenshotTest { @get:Rule val composeRule = createAndroidComposeRule() @@ -73,34 +70,6 @@ class LibraryListsScreenshotTest { composeRule.onRoot().captureRoboImage("src/test/screenshots/library_options.png") } - @Test - fun search() { - composeRule.setContent { - SubtracksTheme { - SearchScreen( - query = "radio", - results = - listOf( - SearchHit("song", "s-eiirp", "Everything In Its Right Place"), - SearchHit("album", "al-kid-a", "Kid A"), - SearchHit("artist", "ar-radiohead", "Radiohead"), - SearchHit("playlist", "pl-late-night", "Late Night"), - ), - onQueryChange = {}, - onBack = {}, - onAlbumClick = {}, - onArtistClick = {}, - onPlaylistClick = {}, - onSongClick = {}, - ) - } - } - composeRule.waitUntil(timeoutMillis = 5_000) { - composeRule.onAllNodesWithText("Radiohead").fetchSemanticsNodes().isNotEmpty() - } - composeRule.onRoot().captureRoboImage("src/test/screenshots/search.png") - } - private fun albums() = (1..7).map { index -> Album( diff --git a/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt new file mode 100644 index 00000000..beeab880 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt @@ -0,0 +1,94 @@ +package com.subtracks.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.remember +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.SongListItem +import com.subtracks.data.prefs.ListQuery +import com.subtracks.ui.library.ALBUM_COVER_TAG +import com.subtracks.ui.library.LibraryScreen +import com.subtracks.ui.library.LibraryTab +import com.subtracks.ui.library.sortOptionsFor +import com.subtracks.ui.theme.SubtracksTheme +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) +class LibrarySearchScreenshotTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun librarySearch() { + composeRule.setContent { + SubtracksTheme { + LibraryScreen( + selectedTab = LibraryTab.Albums, + onTabSelected = {}, + albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(), + artists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + songs = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + playlists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + coverArt = { _, _ -> null }, + onAlbumClick = {}, + onArtistClick = {}, + onPlaylistClick = {}, + onSongClick = {}, + onSync = {}, + onOpenSettings = {}, + listQuery = ListQuery("Name"), + sortOptions = sortOptionsFor(LibraryTab.Albums), + starredSupported = true, + ) + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithContentDescription("List options").performClick() + composeRule.mainClock.advanceTimeBy(1_000) + composeRule.waitForIdle() + composeRule.onAllNodesWithText("Search this list")[0].performClick() + composeRule.mainClock.advanceTimeBy(1_000) + composeRule.waitForIdle() + composeRule.onRoot().captureRoboImage("src/test/screenshots/library_search.png") + } + + private fun albums() = + (1..7).map { index -> + Album( + sourceId = 1, + id = "al-$index", + artistId = "ar-1", + name = "Album $index", + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 1, + frequentRank = null, + recentRank = null, + ) + } +}