mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
add repeat and shuffle modes and fix duplicate track ordering
This commit is contained in:
@@ -0,0 +1,783 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "1fd6e47da427e7d9faceae1bf0774007",
|
||||
"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, `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": "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, `shuffleOrder` TEXT, 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": "shuffleOrder",
|
||||
"columnName": "shuffleOrder",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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, '1fd6e47da427e7d9faceae1bf0774007')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ interface LibraryDao {
|
||||
playlistId: String,
|
||||
): Flow<Playlist?>
|
||||
|
||||
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track")
|
||||
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track, id")
|
||||
fun songsByAlbum(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
|
||||
@@ -52,4 +52,13 @@ val MIGRATION_4_5 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
val MIGRATION_5_6 =
|
||||
object : Migration(5, 6) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleEnabled` INTEGER NOT NULL DEFAULT 0")
|
||||
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `repeatMode` INTEGER NOT NULL DEFAULT 0")
|
||||
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleOrder` TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
||||
|
||||
@@ -30,7 +30,7 @@ import com.subtracks.data.model.SubsonicSource
|
||||
PlaybackCursor::class,
|
||||
ArtworkSeed::class,
|
||||
],
|
||||
version = 5,
|
||||
version = 6,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(QueueKindConverter::class)
|
||||
|
||||
@@ -208,6 +208,9 @@ data class QueueEntry(
|
||||
data class PlaybackCursor(
|
||||
@PrimaryKey val id: Long = 1,
|
||||
val queuePosition: Long,
|
||||
@ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
|
||||
val shuffleOrder: String? = null,
|
||||
)
|
||||
|
||||
@Entity(tableName = "artwork_seeds")
|
||||
|
||||
@@ -19,10 +19,23 @@ data class ResolvedQueueEntry(
|
||||
|
||||
data class QueueSnapshot(
|
||||
val entries: List<ResolvedQueueEntry>,
|
||||
val shuffleOrder: LongArray? = null,
|
||||
) {
|
||||
val size: Long = entries.sumOf { it.length }
|
||||
|
||||
fun locate(position: Long): Pair<QueueEntry, Long>? {
|
||||
val shuffled: Boolean get() = shuffleOrder != null
|
||||
|
||||
fun locate(position: Long): Pair<QueueEntry, Long>? = locateFlat(flatPosition(position) ?: return null)
|
||||
|
||||
fun flatPosition(sequence: Long): Long? = if (shuffleOrder == null) sequence else shuffleOrder.getOrNull(sequence.toInt())
|
||||
|
||||
fun sequencePosition(flat: Long): Long {
|
||||
val order = shuffleOrder ?: return flat
|
||||
val index = order.indexOfFirst { it == flat }
|
||||
return if (index >= 0) index.toLong() else flat
|
||||
}
|
||||
|
||||
private fun locateFlat(position: Long): Pair<QueueEntry, Long>? {
|
||||
var remaining = position
|
||||
for (resolved in entries) {
|
||||
if (remaining < resolved.length) return resolved.entry to remaining
|
||||
@@ -32,6 +45,11 @@ data class QueueSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
data class QueueModes(
|
||||
val shuffle: Boolean,
|
||||
val repeat: Int,
|
||||
)
|
||||
|
||||
data class QueueWindowItem(
|
||||
val position: Long,
|
||||
val item: SongListItem,
|
||||
@@ -59,18 +77,46 @@ class QueueRepository(
|
||||
|
||||
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
|
||||
|
||||
suspend fun replace(entries: List<QueueEntry>) {
|
||||
suspend fun replace(
|
||||
entries: List<QueueEntry>,
|
||||
shuffleOrder: LongArray? = null,
|
||||
) {
|
||||
write(entries)
|
||||
dao.setCursor(PlaybackCursor(queuePosition = 0))
|
||||
dao.setCursor(
|
||||
cursorRow().copy(
|
||||
queuePosition = 0,
|
||||
shuffleEnabled = shuffleOrder != null,
|
||||
shuffleOrder = shuffleOrder?.joinToString(","),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun snapshot(): QueueSnapshot =
|
||||
db.useReaderConnection { transactor ->
|
||||
transactor.deferredTransaction {
|
||||
QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
|
||||
val entries = dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) }
|
||||
val row = dao.cursor()
|
||||
val order =
|
||||
row?.shuffleOrder?.parseOrder()?.takeIf {
|
||||
row.shuffleEnabled &&
|
||||
it.size.toLong() == entries.sumOf { e -> e.length }
|
||||
}
|
||||
QueueSnapshot(entries, order)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun modes(): QueueModes {
|
||||
val row = dao.cursor() ?: return QueueModes(shuffle = false, repeat = 0)
|
||||
return QueueModes(row.shuffleEnabled, row.repeatMode)
|
||||
}
|
||||
|
||||
suspend fun setShuffle(
|
||||
enabled: Boolean,
|
||||
order: LongArray?,
|
||||
) = dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleOrder = order?.joinToString(",")))
|
||||
|
||||
suspend fun setRepeat(mode: Int) = dao.setCursor(cursorRow().copy(repeatMode = mode))
|
||||
|
||||
suspend fun itemAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
@@ -84,6 +130,14 @@ class QueueRepository(
|
||||
first: Long,
|
||||
last: Long,
|
||||
): List<QueueWindowItem> {
|
||||
if (snapshot.shuffled) {
|
||||
val items = mutableListOf<QueueWindowItem>()
|
||||
for (position in first..last) {
|
||||
val (entry, offset) = snapshot.locate(position) ?: continue
|
||||
rows(entry, entry.offset + offset, 1).firstOrNull()?.let { items += QueueWindowItem(position, it) }
|
||||
}
|
||||
return items
|
||||
}
|
||||
val items = mutableListOf<QueueWindowItem>()
|
||||
var start = 0L
|
||||
for (resolved in snapshot.entries) {
|
||||
@@ -112,16 +166,36 @@ class QueueRepository(
|
||||
suspend fun removeAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
) = write(removeEntry(snapshot.entries, position).map { it.entry })
|
||||
): QueueSnapshot {
|
||||
val order = snapshot.shuffleOrder
|
||||
if (order == null) {
|
||||
write(removeEntry(snapshot.entries, position).map { it.entry })
|
||||
return snapshot()
|
||||
}
|
||||
val flat = order.getOrNull(position.toInt()) ?: return snapshot
|
||||
write(removeEntry(snapshot.entries, flat).map { it.entry })
|
||||
val reordered = order.toMutableList().apply { removeAt(position.toInt()) }
|
||||
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
|
||||
setShuffle(true, reordered.toLongArray())
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
suspend fun move(
|
||||
snapshot: QueueSnapshot,
|
||||
from: Long,
|
||||
to: Long,
|
||||
): Boolean {
|
||||
val before = snapshot()
|
||||
val located = before.locate(from) ?: return false
|
||||
val song = itemAt(before, from) ?: return false
|
||||
val removed = removeEntry(before.entries, from)
|
||||
val order = snapshot.shuffleOrder
|
||||
if (order != null) {
|
||||
val reordered = order.toMutableList()
|
||||
val moved = reordered.removeAt(from.toInt())
|
||||
reordered.add(to.coerceIn(0L, reordered.size.toLong()).toInt(), moved)
|
||||
setShuffle(true, reordered.toLongArray())
|
||||
return true
|
||||
}
|
||||
val located = snapshot.locate(from) ?: return false
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
val removed = removeEntry(snapshot.entries, from)
|
||||
val moved = songEntry(located.first.sourceId, song.song.id)
|
||||
write(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved))
|
||||
return true
|
||||
@@ -129,7 +203,9 @@ class QueueRepository(
|
||||
|
||||
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
||||
|
||||
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
|
||||
suspend fun setCursor(position: Long) = dao.setCursor(cursorRow().copy(queuePosition = position))
|
||||
|
||||
private suspend fun cursorRow(): PlaybackCursor = dao.cursor() ?: PlaybackCursor(queuePosition = 0)
|
||||
|
||||
private suspend fun write(entries: List<QueueEntry>) {
|
||||
db.useWriterConnection { transactor ->
|
||||
@@ -231,3 +307,10 @@ class QueueRepository(
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.parseOrder(): LongArray? =
|
||||
if (isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
split(',').mapNotNull { it.toLongOrNull() }.toLongArray().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ class Media3PlayerHandle(
|
||||
controller.seekTo(positionMs)
|
||||
}
|
||||
|
||||
override fun setRepeatOne(enabled: Boolean) {
|
||||
controller.repeatMode = if (enabled) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
||||
}
|
||||
|
||||
override fun addListener(listener: PlayerHandle.Listener) {
|
||||
controller.addListener(
|
||||
object : Player.Listener {
|
||||
|
||||
@@ -33,6 +33,8 @@ data class QueueContext(
|
||||
val refId: String,
|
||||
)
|
||||
|
||||
enum class RepeatMode { Off, All, One }
|
||||
|
||||
data class PlaybackState(
|
||||
val item: QueueItem? = null,
|
||||
val context: QueueContext? = null,
|
||||
@@ -44,12 +46,15 @@ data class PlaybackState(
|
||||
val durationMs: Long = 0,
|
||||
val hasNext: Boolean = false,
|
||||
val hasPrevious: Boolean = false,
|
||||
val shuffle: Boolean = false,
|
||||
val repeat: RepeatMode = RepeatMode.Off,
|
||||
)
|
||||
|
||||
private sealed interface QueueUndo {
|
||||
val entries: List<QueueEntry>
|
||||
val cursor: Long
|
||||
val current: Long?
|
||||
val shuffleOrder: LongArray?
|
||||
}
|
||||
|
||||
private data class RemovedUndo(
|
||||
@@ -58,6 +63,7 @@ private data class RemovedUndo(
|
||||
override val current: Long,
|
||||
val position: Long,
|
||||
val item: QueueItem?,
|
||||
override val shuffleOrder: LongArray?,
|
||||
) : QueueUndo
|
||||
|
||||
private data class MovedUndo(
|
||||
@@ -66,11 +72,13 @@ private data class MovedUndo(
|
||||
override val current: Long,
|
||||
val from: Long,
|
||||
val to: Long,
|
||||
override val shuffleOrder: LongArray?,
|
||||
) : QueueUndo
|
||||
|
||||
private data class ReloadUndo(
|
||||
override val entries: List<QueueEntry>,
|
||||
override val cursor: Long,
|
||||
override val shuffleOrder: LongArray?,
|
||||
) : QueueUndo {
|
||||
override val current: Long? = null
|
||||
}
|
||||
@@ -102,6 +110,9 @@ class PlaybackController(
|
||||
private var windowEnd = -1L
|
||||
private var updating = false
|
||||
private var lastEdit: QueueUndo? = null
|
||||
private var shuffleEnabled = false
|
||||
private var repeatMode = RepeatMode.Off
|
||||
private var endedHandled = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -186,7 +197,7 @@ class PlaybackController(
|
||||
if (snapshot.size == 1L) {
|
||||
queueRepository.removeAt(snapshot, position)
|
||||
stopLocked()
|
||||
lastEdit = ReloadUndo(entries, cursor)
|
||||
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder)
|
||||
return@withLock
|
||||
}
|
||||
val current = currentPosition() ?: return@withLock
|
||||
@@ -198,9 +209,9 @@ class PlaybackController(
|
||||
windowJob?.cancel()
|
||||
lastEdit =
|
||||
if (removed == null) {
|
||||
ReloadUndo(entries, cursor)
|
||||
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
|
||||
} else {
|
||||
RemovedUndo(entries, cursor, target, position, removed)
|
||||
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder)
|
||||
}
|
||||
when {
|
||||
position == current -> {
|
||||
@@ -240,7 +251,7 @@ class PlaybackController(
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val cursor = queueRepository.cursor()
|
||||
val entries = snapshot.entries.map { it.entry }
|
||||
if (!queueRepository.move(from, to)) return@withLock
|
||||
if (!queueRepository.move(snapshot, from, to)) return@withLock
|
||||
this.snapshot = queueRepository.snapshot()
|
||||
val target = movedCursor(current, from, to)
|
||||
windowJob?.cancel()
|
||||
@@ -248,9 +259,9 @@ class PlaybackController(
|
||||
val toInWindow = to in windowStart..windowEnd
|
||||
lastEdit =
|
||||
if (fromInWindow == toInWindow) {
|
||||
MovedUndo(entries, cursor, target, from, to)
|
||||
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder)
|
||||
} else {
|
||||
ReloadUndo(entries, cursor)
|
||||
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
|
||||
}
|
||||
when {
|
||||
fromInWindow && toInWindow -> {
|
||||
@@ -290,7 +301,8 @@ class PlaybackController(
|
||||
val player = player ?: return@withLock
|
||||
val playing = player.playWhenReady
|
||||
val positionMs = player.currentPositionMs
|
||||
queueRepository.replace(undo.entries)
|
||||
queueRepository.replace(undo.entries, undo.shuffleOrder)
|
||||
shuffleEnabled = undo.shuffleOrder != null
|
||||
val restored = queueRepository.snapshot()
|
||||
this.snapshot = restored
|
||||
lastEdit = null
|
||||
@@ -371,9 +383,71 @@ class PlaybackController(
|
||||
if (player.playWhenReady) player.pause() else player.play()
|
||||
}
|
||||
|
||||
fun next() = skip(1)
|
||||
fun next() {
|
||||
val snapshot = snapshot ?: return
|
||||
if (snapshot.size == 0L) return
|
||||
val current = currentPosition() ?: return
|
||||
when {
|
||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun previous() = skip(-1)
|
||||
fun previous() {
|
||||
val player = player ?: return
|
||||
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
|
||||
player.seekTo(0)
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
val snapshot = snapshot ?: return
|
||||
if (snapshot.size == 0L) return
|
||||
val current = currentPosition() ?: return
|
||||
jumpTo(if (current > 0) current - 1 else snapshot.size - 1)
|
||||
}
|
||||
|
||||
fun cycleRepeat() {
|
||||
val next =
|
||||
when (repeatMode) {
|
||||
RepeatMode.Off -> RepeatMode.All
|
||||
RepeatMode.All -> RepeatMode.One
|
||||
RepeatMode.One -> RepeatMode.Off
|
||||
}
|
||||
repeatMode = next
|
||||
player?.setRepeatOne(next == RepeatMode.One)
|
||||
scope.launch { queueRepository.setRepeat(next.ordinal) }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun toggleShuffle() {
|
||||
scope.launch {
|
||||
startLock.withLock {
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this@PlaybackController.snapshot = snapshot
|
||||
if (snapshot.size == 0L) return@withLock
|
||||
val player = player ?: return@withLock
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val positionMs = player.currentPositionMs
|
||||
val playing = player.playWhenReady
|
||||
val flat = snapshot.flatPosition(current) ?: return@withLock
|
||||
if (shuffleEnabled) {
|
||||
queueRepository.setShuffle(false, null)
|
||||
shuffleEnabled = false
|
||||
queueRepository.setCursor(flat)
|
||||
this@PlaybackController.snapshot = queueRepository.snapshot()
|
||||
loadWindow(flat, autoplay = playing, startPositionMs = positionMs)
|
||||
} else {
|
||||
queueRepository.setShuffle(true, shuffledOrder(snapshot.size, flat))
|
||||
shuffleEnabled = true
|
||||
queueRepository.setCursor(0)
|
||||
this@PlaybackController.snapshot = queueRepository.snapshot()
|
||||
loadWindow(0, autoplay = playing, startPositionMs = positionMs)
|
||||
}
|
||||
endedHandled = false
|
||||
refresh(if (shuffleEnabled) 0L else flat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
player?.seekTo(positionMs)
|
||||
@@ -413,6 +487,11 @@ class PlaybackController(
|
||||
lastDurationMs = 0
|
||||
lastError = null
|
||||
lastEdit = null
|
||||
endedHandled = false
|
||||
val modes = queueRepository.modes()
|
||||
shuffleEnabled = modes.shuffle
|
||||
repeatMode = modes.repeat.toRepeatMode()
|
||||
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
||||
queueRepository.replace(entries)
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
@@ -421,7 +500,14 @@ class PlaybackController(
|
||||
stopLocked()
|
||||
return@withLock
|
||||
}
|
||||
val position = startPosition.coerceIn(0, snapshot.size - 1)
|
||||
val start = startPosition.coerceIn(0, snapshot.size - 1)
|
||||
val position =
|
||||
if (shuffleEnabled) {
|
||||
queueRepository.setShuffle(true, shuffledOrder(snapshot.size, start))
|
||||
0L
|
||||
} else {
|
||||
start
|
||||
}
|
||||
queueRepository.setCursor(position)
|
||||
loadWindow(position, autoplay = true)
|
||||
}
|
||||
@@ -431,6 +517,11 @@ class PlaybackController(
|
||||
windowJob?.cancel()
|
||||
windowStart = 0
|
||||
windowEnd = -1
|
||||
endedHandled = false
|
||||
val modes = queueRepository.modes()
|
||||
shuffleEnabled = modes.shuffle
|
||||
repeatMode = modes.repeat.toRepeatMode()
|
||||
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
val queueSourceId =
|
||||
@@ -492,22 +583,24 @@ class PlaybackController(
|
||||
refresh(position)
|
||||
}
|
||||
|
||||
private fun skip(delta: Long) {
|
||||
private fun jumpTo(target: Long) {
|
||||
scope.launch {
|
||||
val current = currentPosition() ?: return@launch
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this@PlaybackController.snapshot = snapshot
|
||||
if (snapshot.size == 0L) return@launch
|
||||
val position = current.coerceIn(0, snapshot.size - 1)
|
||||
val target = (position + delta).coerceIn(0, snapshot.size - 1)
|
||||
if (target == position) return@launch
|
||||
val current = currentPosition() ?: return@launch
|
||||
val dest = target.coerceIn(0, snapshot.size - 1)
|
||||
if (dest == current) return@launch
|
||||
windowJob?.cancel()
|
||||
queueRepository.setCursor(target)
|
||||
shiftWindow(target)
|
||||
queueRepository.setCursor(dest)
|
||||
shiftWindow(dest)
|
||||
val player = player ?: return@launch
|
||||
val wasEnded = player.isEnded
|
||||
player.ensurePrepared()
|
||||
player.seekToIndex((target - windowStart).toInt())
|
||||
refresh(target)
|
||||
player.seekToIndex((dest - windowStart).toInt())
|
||||
if (wasEnded) player.play()
|
||||
endedHandled = false
|
||||
refresh(dest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,11 +681,15 @@ class PlaybackController(
|
||||
object : PlayerHandle.Listener {
|
||||
override fun onTransition() {
|
||||
lastError = null
|
||||
endedHandled = false
|
||||
if (updating) return
|
||||
scope.launch { onPositionChanged() }
|
||||
}
|
||||
|
||||
override fun onEvents() = refresh()
|
||||
override fun onEvents() {
|
||||
if (player?.isEnded == true) handleEnded()
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
lastError = message
|
||||
@@ -600,6 +697,22 @@ class PlaybackController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEnded() {
|
||||
if (endedHandled) return
|
||||
endedHandled = true
|
||||
if (repeatMode == RepeatMode.All) jumpTo(0)
|
||||
}
|
||||
|
||||
private fun shuffledOrder(
|
||||
size: Long,
|
||||
first: Long,
|
||||
): LongArray {
|
||||
val rest = (0 until size).filter { it != first }.shuffled()
|
||||
return (listOf(first) + rest).toLongArray()
|
||||
}
|
||||
|
||||
private fun Int.toRepeatMode(): RepeatMode = RepeatMode.entries.getOrElse(this) { RepeatMode.Off }
|
||||
|
||||
private fun contextAt(position: Long?): QueueContext? {
|
||||
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null
|
||||
return QueueContext(entry.kind, entry.refId)
|
||||
@@ -626,7 +739,6 @@ class PlaybackController(
|
||||
private fun refresh(position: Long? = currentPosition()) {
|
||||
val player = player ?: return
|
||||
updateBuffering()
|
||||
val size = snapshot?.size ?: 0
|
||||
lastDurationMs = player.durationMs.takeIf { it > 0 } ?: lastDurationMs
|
||||
_state.value =
|
||||
PlaybackState(
|
||||
@@ -638,8 +750,10 @@ class PlaybackController(
|
||||
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
|
||||
positionMs = player.currentPositionMs,
|
||||
durationMs = lastDurationMs,
|
||||
hasNext = position != null && position < size - 1,
|
||||
hasPrevious = position != null && position > 0,
|
||||
hasNext = true,
|
||||
hasPrevious = true,
|
||||
shuffle = shuffleEnabled,
|
||||
repeat = repeatMode,
|
||||
)
|
||||
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
|
||||
}
|
||||
@@ -670,5 +784,6 @@ class PlaybackController(
|
||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||
const val QUEUE_WINDOW_RADIUS = 25L
|
||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||
const val RESTART_THRESHOLD_MS = 3_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ interface PlayerHandle {
|
||||
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
fun setRepeatOne(enabled: Boolean)
|
||||
|
||||
fun addListener(listener: Listener)
|
||||
|
||||
interface Listener {
|
||||
|
||||
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||
import androidx.compose.material.icons.rounded.PauseCircle
|
||||
import androidx.compose.material.icons.rounded.PlayCircle
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
import androidx.compose.material.icons.rounded.RepeatOne
|
||||
import androidx.compose.material.icons.rounded.Shuffle
|
||||
import androidx.compose.material.icons.rounded.SkipNext
|
||||
import androidx.compose.material.icons.rounded.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -57,6 +60,7 @@ import coil3.request.ImageRequest
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.RepeatMode
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||
@@ -91,6 +95,8 @@ fun NowPlayingRoute(
|
||||
onPlayPause = controller::togglePlayPause,
|
||||
onNext = controller::next,
|
||||
onPrevious = controller::previous,
|
||||
onShuffle = controller::toggleShuffle,
|
||||
onRepeat = controller::cycleRepeat,
|
||||
onSeek = controller::seekTo,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -121,6 +127,8 @@ fun NowPlayingScreen(
|
||||
onPlayPause: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onShuffle: () -> Unit = {},
|
||||
onRepeat: () -> Unit = {},
|
||||
onSeek: (Long) -> Unit,
|
||||
thumbnailRef: CoverArtRef? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -315,7 +323,8 @@ fun NowPlayingScreen(
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 56.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
@@ -324,6 +333,41 @@ fun NowPlayingScreen(
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onShuffle, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Shuffle,
|
||||
contentDescription = "Shuffle",
|
||||
tint =
|
||||
if (state.shuffle) {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRepeat, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = if (state.repeat == RepeatMode.One) Icons.Rounded.RepeatOne else Icons.Rounded.Repeat,
|
||||
contentDescription =
|
||||
when (state.repeat) {
|
||||
RepeatMode.Off -> "Repeat off"
|
||||
RepeatMode.All -> "Repeat all"
|
||||
RepeatMode.One -> "Repeat one"
|
||||
},
|
||||
tint =
|
||||
if (state.repeat ==
|
||||
RepeatMode.Off
|
||||
) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ class QueueRepositoryTest {
|
||||
seedLibrary()
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
|
||||
repository.move(from = 0, to = 2)
|
||||
repository.move(repository.snapshot(), from = 0, to = 2)
|
||||
|
||||
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
||||
}
|
||||
@@ -296,7 +296,7 @@ class QueueRepositoryTest {
|
||||
seedLibrary()
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
|
||||
|
||||
repository.move(from = 0, to = 3)
|
||||
repository.move(repository.snapshot(), from = 0, to = 3)
|
||||
|
||||
assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot()))
|
||||
}
|
||||
@@ -307,8 +307,8 @@ class QueueRepositoryTest {
|
||||
seedLibrary()
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
|
||||
assertFalse(repository.move(from = 5, to = 0))
|
||||
assertTrue(repository.move(from = 0, to = 2))
|
||||
assertFalse(repository.move(repository.snapshot(), from = 5, to = 0))
|
||||
assertTrue(repository.move(repository.snapshot(), from = 0, to = 2))
|
||||
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ class FakePlayerHandle : PlayerHandle {
|
||||
@Volatile
|
||||
var duration = 0L
|
||||
|
||||
@Volatile
|
||||
var positionMs = 0L
|
||||
|
||||
private val listeners = CopyOnWriteArrayList<PlayerHandle.Listener>()
|
||||
|
||||
@Volatile
|
||||
@@ -54,7 +57,7 @@ class FakePlayerHandle : PlayerHandle {
|
||||
|
||||
override val durationMs: Long get() = duration
|
||||
|
||||
override val currentPositionMs: Long get() = 0
|
||||
override val currentPositionMs: Long get() = positionMs
|
||||
|
||||
override val currentItem: QueueItem? get() = items.getOrNull(index)
|
||||
|
||||
@@ -190,6 +193,10 @@ class FakePlayerHandle : PlayerHandle {
|
||||
notifyEvents()
|
||||
}
|
||||
|
||||
override fun setRepeatOne(enabled: Boolean) {
|
||||
operations += "setRepeatOne($enabled)"
|
||||
}
|
||||
|
||||
override fun addListener(listener: PlayerHandle.Listener) {
|
||||
listeners += listener
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ class PlaybackControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLastTrackReportsNoNext() {
|
||||
fun theLastTrackStillEnablesNext() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
@@ -178,7 +178,7 @@ class PlaybackControllerTest {
|
||||
?.id == "s3"
|
||||
}
|
||||
|
||||
assertFalse(controller.state.value.hasNext)
|
||||
assertTrue(controller.state.value.hasNext)
|
||||
assertTrue(controller.state.value.hasPrevious)
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ class PlaybackControllerTest {
|
||||
assertEquals(QueueContext(QueueKind.Album, "al1"), controller.state.value.context)
|
||||
assertTrue(controller.state.value.isPlaying)
|
||||
assertTrue(controller.state.value.hasNext)
|
||||
assertFalse(controller.state.value.hasPrevious)
|
||||
assertTrue(controller.state.value.hasPrevious)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -371,7 +371,7 @@ class PlaybackControllerTest {
|
||||
|
||||
assertEquals(0L, controller.state.value.position)
|
||||
assertTrue(handle.operations.any { it.startsWith("setWindow") })
|
||||
assertFalse(controller.state.value.hasPrevious)
|
||||
assertTrue(controller.state.value.hasPrevious)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -595,6 +595,137 @@ class PlaybackControllerTest {
|
||||
assertEquals(listOf("s1", "s2", "s3"), handle.items.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffleKeepsTheCurrentTrackAndRestoresTheOriginalOrder() {
|
||||
seedAlbum(5, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
|
||||
controller.toggleShuffle()
|
||||
await { controller.state.value.shuffle }
|
||||
|
||||
assertEquals(
|
||||
"s3",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
assertEquals(0L, controller.state.value.position)
|
||||
assertEquals("s3", handle.items.first().id)
|
||||
|
||||
controller.toggleShuffle()
|
||||
await { !controller.state.value.shuffle }
|
||||
|
||||
assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id })
|
||||
assertEquals(
|
||||
"s3",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextWrapsToTheStartWhenRepeating() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
|
||||
controller.cycleRepeat()
|
||||
assertEquals(RepeatMode.All, controller.state.value.repeat)
|
||||
|
||||
controller.next()
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextOnTheLastTrackDoesNothingWithoutRepeat() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
|
||||
controller.next()
|
||||
Thread.sleep(100)
|
||||
|
||||
assertEquals(
|
||||
"s3",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previousRestartsTheTrackWhenPastTheThreshold() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 1)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s2"
|
||||
}
|
||||
|
||||
handle.positionMs = 5_000L
|
||||
controller.previous()
|
||||
|
||||
assertTrue(handle.operations.contains("seekTo(0)"))
|
||||
assertEquals(
|
||||
"s2",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previousFromTheStartWrapsToTheLastTrack() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
|
||||
controller.previous()
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatAllRestartsTheQueueWhenItEnds() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
|
||||
controller.cycleRepeat()
|
||||
assertEquals(RepeatMode.All, controller.state.value.repeat)
|
||||
|
||||
handle.finish()
|
||||
handle.emitEvents()
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedAlbum(
|
||||
count: Int,
|
||||
sourceId: Long,
|
||||
|
||||
Reference in New Issue
Block a user