rework the library lists: inline filter, playlist updated, starred sort, clearer controls

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 9a99977342
commit a2d42f7957
25 changed files with 1460 additions and 408 deletions
+823
View File
@@ -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')"
]
}
}
@@ -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<Int, Album>
@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<Int, Album>
@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<Int, Album>
@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<Int, Album>
@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<Int, Album>
@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<Int, Album>
@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<Int, Album>
@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<Int, Album>
@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<Int, Album>
@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<Int, Album>
@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<Int, Artist>
@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<Int, Artist>
@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<Int, Artist>
@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<Int, Artist>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun playlistsByName(sourceId: Long): PagingSource<Int, Playlist>
@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<Int, Artist>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE DESC")
fun playlistsByNameReversed(sourceId: Long): PagingSource<Int, Playlist>
@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<Int, Artist>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE")
fun playlistsByRecentlyAdded(sourceId: Long): PagingSource<Int, Playlist>
@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<Int, Playlist>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created ASC, name COLLATE NOCASE")
fun playlistsByRecentlyAddedReversed(sourceId: Long): PagingSource<Int, Playlist>
@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<Int, Playlist>
@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<Int, Playlist>
@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<Int, Playlist>
@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<Int, Playlist>
@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<Int, Playlist>
@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<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (: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<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (: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<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (: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<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (: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<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (: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<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (: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<Int, SongListItem>
@Query(PLAYLIST_SONGS_SQL)
@@ -72,4 +72,12 @@ val MIGRATION_6_7 =
}
}
val MIGRATIONS: Array<Migration> = 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<Migration> =
arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 7,
version = 8,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -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,
)
@@ -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,
@@ -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<PagingData<Album>> =
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<PagingData<Artist>> =
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<PagingData<Playlist>> =
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<PagingData<SongListItem>> =
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<SearchHit> =
db
.searchDao()
.search(sourceId.toString(), query, limit)
.map { SearchHit(it.type, it.itemId, it.title) }
fun album(
sourceId: Long,
albumId: String,
@@ -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,
)
@@ -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<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, duration)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
@@ -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()) }
@@ -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,
@@ -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) },
@@ -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,
@@ -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,
@@ -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> =
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<SortOption> =
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> =
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(
@@ -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, MutableStateFlow<String>> =
LibraryListTab.entries.associateWith { MutableStateFlow("") }
val albums: Flow<PagingData<Album>> =
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<String> = searches.getValue(tab)
fun setSearch(
tab: LibraryListTab,
value: String,
) {
searches.getValue(tab).value = value
}
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
@@ -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,
@@ -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<SearchHit>,
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<SearchHit>,
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
}
@@ -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<List<SearchHit>> =
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
}
}
@@ -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,
@@ -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,
@@ -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()
}
}
@@ -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,
@@ -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<ComponentActivity>()
@@ -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(
@@ -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<ComponentActivity>()
@Test
fun librarySearch() {
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = LibraryTab.Albums,
onTabSelected = {},
albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(),
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
songs = remember { flowOf(PagingData.empty<SongListItem>()) }.collectAsLazyPagingItems(),
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.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,
)
}
}