mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
song added sort, robust scroll reset, restyled search and filter controls
This commit is contained in:
@@ -0,0 +1,830 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "7cac4ec967b6e0b53bab01a092dbc0f9",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "sources",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "address",
|
||||
"columnName": "address",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isActive",
|
||||
"columnName": "isActive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "subsonic_sources",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
"columnName": "sourceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useTokenAuth",
|
||||
"columnName": "useTokenAuth",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sourceId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_subsonic_sources_sourceId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "sources",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"sourceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "artists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
"columnName": "sourceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCount",
|
||||
"columnName": "albumCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "starred",
|
||||
"columnName": "starred",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_artists_sourceId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "sources",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"sourceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "albums",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL, `albumArtist` TEXT, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `frequentRank` INTEGER, `recentRank` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
"columnName": "sourceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumArtist",
|
||||
"columnName": "albumArtist",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "created",
|
||||
"columnName": "created",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "starred",
|
||||
"columnName": "starred",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "songCount",
|
||||
"columnName": "songCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "frequentRank",
|
||||
"columnName": "frequentRank",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "recentRank",
|
||||
"columnName": "recentRank",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_albums_sourceId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_albums_sourceId_artistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"artistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "sources",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"sourceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
"columnName": "sourceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "comment",
|
||||
"columnName": "comment",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "songCount",
|
||||
"columnName": "songCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "created",
|
||||
"columnName": "created",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "changed",
|
||||
"columnName": "changed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlists_sourceId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "sources",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"sourceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlist_songs",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
"columnName": "sourceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playlistId",
|
||||
"columnName": "playlistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"playlistId",
|
||||
"position"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlist_songs_sourceId_playlistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"playlistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_playlist_songs_sourceId_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "sources",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"sourceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "songs",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `genre` TEXT, `created` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
"columnName": "sourceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "album",
|
||||
"columnName": "album",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artist",
|
||||
"columnName": "artist",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "track",
|
||||
"columnName": "track",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "disc",
|
||||
"columnName": "disc",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "starred",
|
||||
"columnName": "starred",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "created",
|
||||
"columnName": "created",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_songs_sourceId_albumId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"albumId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_songs_sourceId_artistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId",
|
||||
"artistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "sources",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"sourceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "search_index",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`sourceId` UNINDEXED, `type` UNINDEXED, `itemId` UNINDEXED, `title`, tokenize=`trigram`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
"columnName": "sourceId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "itemId",
|
||||
"columnName": "itemId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"rowid"
|
||||
]
|
||||
},
|
||||
"ftsVersion": "FTS5",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "trigram",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [
|
||||
"sourceId",
|
||||
"type",
|
||||
"itemId"
|
||||
],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC",
|
||||
"contentRowId": "",
|
||||
"columnSize": true,
|
||||
"detail": "FULL"
|
||||
},
|
||||
"contentSyncTriggers": []
|
||||
},
|
||||
{
|
||||
"tableName": "queue_entries",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
"columnName": "sourceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "kind",
|
||||
"columnName": "kind",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "refId",
|
||||
"columnName": "refId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "rangeStart",
|
||||
"columnName": "rangeStart",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "rangeEnd",
|
||||
"columnName": "rangeEnd",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_queue_entries_sourceId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sourceId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_queue_entries_position",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"position"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "sources",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"sourceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playback_cursor",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "queuePosition",
|
||||
"columnName": "queuePosition",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shuffleEnabled",
|
||||
"columnName": "shuffleEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatMode",
|
||||
"columnName": "repeatMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "shuffleSeed",
|
||||
"columnName": "shuffleSeed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "positionMs",
|
||||
"columnName": "positionMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "shuffle_order",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER NOT NULL, `flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sequence",
|
||||
"columnName": "sequence",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "flatPosition",
|
||||
"columnName": "flatPosition",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sequence"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "artwork_seeds",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, PRIMARY KEY(`cacheKey`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "cacheKey",
|
||||
"columnName": "cacheKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "primary",
|
||||
"columnName": "primary",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "secondary",
|
||||
"columnName": "secondary",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"cacheKey"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7cac4ec967b6e0b53bab01a092dbc0f9')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -454,6 +454,38 @@ interface LibraryDao {
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY songs.created DESC, songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun songsByAdded(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY songs.created ASC, songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun songsByAddedReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@Query(PLAYLIST_SONGS_SQL)
|
||||
fun playlistSongs(
|
||||
sourceId: Long,
|
||||
|
||||
@@ -79,5 +79,12 @@ val MIGRATION_7_8 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_8_9 =
|
||||
object : Migration(8, 9) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `created` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> =
|
||||
arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
|
||||
arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
|
||||
|
||||
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
|
||||
ShuffleOrder::class,
|
||||
ArtworkSeed::class,
|
||||
],
|
||||
version = 8,
|
||||
version = 9,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(QueueKindConverter::class)
|
||||
|
||||
@@ -160,6 +160,7 @@ data class Song(
|
||||
val disc: Long?,
|
||||
val starred: Long?,
|
||||
val genre: String?,
|
||||
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
||||
)
|
||||
|
||||
data class SongListItem(
|
||||
|
||||
@@ -16,7 +16,7 @@ enum class ArtistSort { Name, AlbumCount, Starred }
|
||||
|
||||
enum class PlaylistSort { Name, Added, Updated }
|
||||
|
||||
enum class SongSort { Album, Title, Artist, Starred }
|
||||
enum class SongSort { Album, Title, Artist, Starred, Added }
|
||||
|
||||
enum class LibraryListTab(
|
||||
val key: String,
|
||||
|
||||
@@ -183,6 +183,14 @@ class LibraryRepository(
|
||||
dao.songsByStarred(sourceId, starred.ordinal, search)
|
||||
}
|
||||
}
|
||||
|
||||
SongSort.Added -> {
|
||||
if (descending) {
|
||||
dao.songsByAddedReversed(sourceId, starred.ordinal, search)
|
||||
} else {
|
||||
dao.songsByAdded(sourceId, starred.ordinal, search)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ object SubsonicXml {
|
||||
disc = element.longAttr("discNumber"),
|
||||
starred = element.dateAttr("starred"),
|
||||
genre = element.attr("genre").ifEmpty { null },
|
||||
created = element.dateAttr("created") ?: 0L,
|
||||
)
|
||||
|
||||
fun playlists(
|
||||
|
||||
@@ -117,6 +117,7 @@ private val songColumns =
|
||||
"disc",
|
||||
"starred",
|
||||
"genre",
|
||||
"created",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
@@ -130,7 +131,8 @@ private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starr
|
||||
private fun Album.values() =
|
||||
listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount, frequentRank, recentRank)
|
||||
|
||||
private fun Song.values() = listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre)
|
||||
private fun Song.values() =
|
||||
listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
|
||||
|
||||
@@ -3,14 +3,49 @@ package com.subtracks.ui.components
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.paging.LoadState
|
||||
|
||||
private const val FILL_EPSILON_PX = 1
|
||||
|
||||
/**
|
||||
* Scrolls back to the top once a new filter/sort has actually loaded.
|
||||
*
|
||||
* Scrolling immediately after [resetKey] changes does not stick: the lazy layout keeps the
|
||||
* previously visible item in place by key while the paged list reloads, which snaps the scroll
|
||||
* back. Waiting for the refresh triggered by the change and resetting afterwards avoids that.
|
||||
*/
|
||||
@Composable
|
||||
fun ResetScrollOnChange(
|
||||
resetKey: Any?,
|
||||
refreshState: () -> LoadState,
|
||||
onReset: suspend () -> Unit,
|
||||
) {
|
||||
var pending by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(resetKey) { pending = true }
|
||||
LaunchedEffect(Unit) {
|
||||
var refreshes = 0
|
||||
var handled = 0
|
||||
snapshotFlow(refreshState).collect { state ->
|
||||
if (state is LoadState.Loading) {
|
||||
refreshes++
|
||||
} else if (pending && refreshes > handled) {
|
||||
onReset()
|
||||
handled = refreshes
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberViewportFill(state: LazyListState): Dp {
|
||||
val density = LocalDensity.current
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -29,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.FilteredEmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
@@ -60,7 +60,7 @@ fun AlbumsContent(
|
||||
|
||||
else -> {
|
||||
val gridState = rememberLazyGridState()
|
||||
LaunchedEffect(resetKey) { gridState.scrollToItem(0) }
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
||||
val fill = rememberViewportFill(gridState)
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -29,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.FilteredEmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
@@ -58,7 +58,7 @@ fun ArtistsContent(
|
||||
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -32,6 +33,7 @@ import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.FilterAltOff
|
||||
import androidx.compose.material.icons.rounded.MusicNote
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Remove
|
||||
@@ -46,20 +48,17 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -153,6 +152,7 @@ fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
|
||||
SortOption(SongSort.Title.name, "Title"),
|
||||
SortOption(SongSort.Artist.name, "Artist"),
|
||||
SortOption(SongSort.Starred.name, "Starred"),
|
||||
SortOption(SongSort.Added.name, "Added"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -327,56 +327,54 @@ fun LibraryScreen(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
) { page ->
|
||||
key(resetKey) {
|
||||
when (LibraryTab.entries[page]) {
|
||||
LibraryTab.Albums -> {
|
||||
AlbumsContent(
|
||||
albums,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onAlbumClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
when (LibraryTab.entries[page]) {
|
||||
LibraryTab.Albums -> {
|
||||
AlbumsContent(
|
||||
albums,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onAlbumClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Artists -> {
|
||||
ArtistsContent(
|
||||
artists,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onArtistClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
LibraryTab.Artists -> {
|
||||
ArtistsContent(
|
||||
artists,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onArtistClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Songs -> {
|
||||
SongsContent(
|
||||
songs,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onSongClick,
|
||||
playingSongId,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
LibraryTab.Songs -> {
|
||||
SongsContent(
|
||||
songs,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onSongClick,
|
||||
playingSongId,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Playlists -> {
|
||||
PlaylistsContent(
|
||||
playlists,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onPlaylistClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
LibraryTab.Playlists -> {
|
||||
PlaylistsContent(
|
||||
playlists,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onPlaylistClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -456,12 +454,22 @@ private fun ListOptionsSheet(
|
||||
onSearch: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Search this list") },
|
||||
leadingContent = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(onClick = onSearch),
|
||||
)
|
||||
Surface(
|
||||
onClick = onSearch,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Search this list", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
@@ -488,18 +496,30 @@ private fun ListOptionsSheet(
|
||||
sortOptions.forEach { option ->
|
||||
FilterChip(
|
||||
selected = listQuery.sort == option.value,
|
||||
onClick = { onSortChange(option.value) },
|
||||
onClick = {
|
||||
if (listQuery.sort == option.value) onToggleSortDirection() else onSortChange(option.value)
|
||||
},
|
||||
label = { Text(option.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (starredSupported) {
|
||||
Text(
|
||||
text = "Filters",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Filters",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (listQuery.starred != StarredFilter.Any) {
|
||||
IconButton(onClick = onClearFilters) {
|
||||
Icon(Icons.Rounded.FilterAltOff, contentDescription = "Clear filters")
|
||||
}
|
||||
}
|
||||
}
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
@@ -535,9 +555,6 @@ private fun ListOptionsSheet(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (listQuery.starred != StarredFilter.Any) {
|
||||
TextButton(onClick = onClearFilters) { Text("Clear filters") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -555,11 +572,11 @@ private fun SearchField(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
TextField(
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
placeholder = { Text("Search") },
|
||||
label = { Text("Search") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
@@ -567,11 +584,9 @@ private fun SearchField(
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.background,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -29,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.FilteredEmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
|
||||
@Composable
|
||||
@@ -57,7 +57,7 @@ fun PlaylistsContent(
|
||||
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -21,7 +21,6 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -40,6 +39,7 @@ import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.FilteredEmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
|
||||
@Composable
|
||||
@@ -69,7 +69,7 @@ fun SongsContent(
|
||||
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -111,6 +111,28 @@ class LibraryDaoTest {
|
||||
assertEquals(listOf("Beta"), dao.albumsByName(sourceId, starredFilter = 2, search = "").page().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songAddedSortOrdersByCreated() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "Zebra", starred = null, created = 100),
|
||||
song(sourceId, "s2", "Apple", starred = null, created = 200),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Apple", "Zebra"),
|
||||
dao.songsByAdded(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Zebra", "Apple"),
|
||||
dao.songsByAddedReversed(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun source(): Long =
|
||||
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
|
||||
|
||||
@@ -149,6 +171,7 @@ class LibraryDaoTest {
|
||||
id: String,
|
||||
title: String,
|
||||
starred: Long?,
|
||||
created: Long = 0,
|
||||
) = Song(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
@@ -162,5 +185,6 @@ class LibraryDaoTest {
|
||||
disc = 1,
|
||||
starred = starred,
|
||||
genre = null,
|
||||
created = created,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class LibraryOptionsTest {
|
||||
composeRule.waitForIdle()
|
||||
assertEquals(StarredFilter.Starred, query.starred)
|
||||
|
||||
composeRule.onNodeWithText("Clear filters").performClick()
|
||||
composeRule.onNodeWithContentDescription("Clear filters").performClick()
|
||||
composeRule.waitForIdle()
|
||||
assertEquals(StarredFilter.Any, query.starred)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user