mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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,
|
playlistId: String,
|
||||||
): Flow<Playlist?>
|
): 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(
|
fun songsByAlbum(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
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,
|
PlaybackCursor::class,
|
||||||
ArtworkSeed::class,
|
ArtworkSeed::class,
|
||||||
],
|
],
|
||||||
version = 5,
|
version = 6,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(QueueKindConverter::class)
|
@ColumnTypeConverters(QueueKindConverter::class)
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ data class QueueEntry(
|
|||||||
data class PlaybackCursor(
|
data class PlaybackCursor(
|
||||||
@PrimaryKey val id: Long = 1,
|
@PrimaryKey val id: Long = 1,
|
||||||
val queuePosition: Long,
|
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")
|
@Entity(tableName = "artwork_seeds")
|
||||||
|
|||||||
@@ -19,10 +19,23 @@ data class ResolvedQueueEntry(
|
|||||||
|
|
||||||
data class QueueSnapshot(
|
data class QueueSnapshot(
|
||||||
val entries: List<ResolvedQueueEntry>,
|
val entries: List<ResolvedQueueEntry>,
|
||||||
|
val shuffleOrder: LongArray? = null,
|
||||||
) {
|
) {
|
||||||
val size: Long = entries.sumOf { it.length }
|
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
|
var remaining = position
|
||||||
for (resolved in entries) {
|
for (resolved in entries) {
|
||||||
if (remaining < resolved.length) return resolved.entry to remaining
|
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(
|
data class QueueWindowItem(
|
||||||
val position: Long,
|
val position: Long,
|
||||||
val item: SongListItem,
|
val item: SongListItem,
|
||||||
@@ -59,18 +77,46 @@ class QueueRepository(
|
|||||||
|
|
||||||
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
|
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)
|
write(entries)
|
||||||
dao.setCursor(PlaybackCursor(queuePosition = 0))
|
dao.setCursor(
|
||||||
|
cursorRow().copy(
|
||||||
|
queuePosition = 0,
|
||||||
|
shuffleEnabled = shuffleOrder != null,
|
||||||
|
shuffleOrder = shuffleOrder?.joinToString(","),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun snapshot(): QueueSnapshot =
|
suspend fun snapshot(): QueueSnapshot =
|
||||||
db.useReaderConnection { transactor ->
|
db.useReaderConnection { transactor ->
|
||||||
transactor.deferredTransaction {
|
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(
|
suspend fun itemAt(
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
position: Long,
|
position: Long,
|
||||||
@@ -84,6 +130,14 @@ class QueueRepository(
|
|||||||
first: Long,
|
first: Long,
|
||||||
last: Long,
|
last: Long,
|
||||||
): List<QueueWindowItem> {
|
): 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>()
|
val items = mutableListOf<QueueWindowItem>()
|
||||||
var start = 0L
|
var start = 0L
|
||||||
for (resolved in snapshot.entries) {
|
for (resolved in snapshot.entries) {
|
||||||
@@ -112,16 +166,36 @@ class QueueRepository(
|
|||||||
suspend fun removeAt(
|
suspend fun removeAt(
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
position: Long,
|
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(
|
suspend fun move(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val before = snapshot()
|
val order = snapshot.shuffleOrder
|
||||||
val located = before.locate(from) ?: return false
|
if (order != null) {
|
||||||
val song = itemAt(before, from) ?: return false
|
val reordered = order.toMutableList()
|
||||||
val removed = removeEntry(before.entries, from)
|
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)
|
val moved = songEntry(located.first.sourceId, song.song.id)
|
||||||
write(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved))
|
write(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved))
|
||||||
return true
|
return true
|
||||||
@@ -129,7 +203,9 @@ class QueueRepository(
|
|||||||
|
|
||||||
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
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>) {
|
private suspend fun write(entries: List<QueueEntry>) {
|
||||||
db.useWriterConnection { transactor ->
|
db.useWriterConnection { transactor ->
|
||||||
@@ -231,3 +307,10 @@ class QueueRepository(
|
|||||||
return result
|
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)
|
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) {
|
override fun addListener(listener: PlayerHandle.Listener) {
|
||||||
controller.addListener(
|
controller.addListener(
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ data class QueueContext(
|
|||||||
val refId: String,
|
val refId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class RepeatMode { Off, All, One }
|
||||||
|
|
||||||
data class PlaybackState(
|
data class PlaybackState(
|
||||||
val item: QueueItem? = null,
|
val item: QueueItem? = null,
|
||||||
val context: QueueContext? = null,
|
val context: QueueContext? = null,
|
||||||
@@ -44,12 +46,15 @@ data class PlaybackState(
|
|||||||
val durationMs: Long = 0,
|
val durationMs: Long = 0,
|
||||||
val hasNext: Boolean = false,
|
val hasNext: Boolean = false,
|
||||||
val hasPrevious: Boolean = false,
|
val hasPrevious: Boolean = false,
|
||||||
|
val shuffle: Boolean = false,
|
||||||
|
val repeat: RepeatMode = RepeatMode.Off,
|
||||||
)
|
)
|
||||||
|
|
||||||
private sealed interface QueueUndo {
|
private sealed interface QueueUndo {
|
||||||
val entries: List<QueueEntry>
|
val entries: List<QueueEntry>
|
||||||
val cursor: Long
|
val cursor: Long
|
||||||
val current: Long?
|
val current: Long?
|
||||||
|
val shuffleOrder: LongArray?
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class RemovedUndo(
|
private data class RemovedUndo(
|
||||||
@@ -58,6 +63,7 @@ private data class RemovedUndo(
|
|||||||
override val current: Long,
|
override val current: Long,
|
||||||
val position: Long,
|
val position: Long,
|
||||||
val item: QueueItem?,
|
val item: QueueItem?,
|
||||||
|
override val shuffleOrder: LongArray?,
|
||||||
) : QueueUndo
|
) : QueueUndo
|
||||||
|
|
||||||
private data class MovedUndo(
|
private data class MovedUndo(
|
||||||
@@ -66,11 +72,13 @@ private data class MovedUndo(
|
|||||||
override val current: Long,
|
override val current: Long,
|
||||||
val from: Long,
|
val from: Long,
|
||||||
val to: Long,
|
val to: Long,
|
||||||
|
override val shuffleOrder: LongArray?,
|
||||||
) : QueueUndo
|
) : QueueUndo
|
||||||
|
|
||||||
private data class ReloadUndo(
|
private data class ReloadUndo(
|
||||||
override val entries: List<QueueEntry>,
|
override val entries: List<QueueEntry>,
|
||||||
override val cursor: Long,
|
override val cursor: Long,
|
||||||
|
override val shuffleOrder: LongArray?,
|
||||||
) : QueueUndo {
|
) : QueueUndo {
|
||||||
override val current: Long? = null
|
override val current: Long? = null
|
||||||
}
|
}
|
||||||
@@ -102,6 +110,9 @@ class PlaybackController(
|
|||||||
private var windowEnd = -1L
|
private var windowEnd = -1L
|
||||||
private var updating = false
|
private var updating = false
|
||||||
private var lastEdit: QueueUndo? = null
|
private var lastEdit: QueueUndo? = null
|
||||||
|
private var shuffleEnabled = false
|
||||||
|
private var repeatMode = RepeatMode.Off
|
||||||
|
private var endedHandled = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -186,7 +197,7 @@ class PlaybackController(
|
|||||||
if (snapshot.size == 1L) {
|
if (snapshot.size == 1L) {
|
||||||
queueRepository.removeAt(snapshot, position)
|
queueRepository.removeAt(snapshot, position)
|
||||||
stopLocked()
|
stopLocked()
|
||||||
lastEdit = ReloadUndo(entries, cursor)
|
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder)
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
val current = currentPosition() ?: return@withLock
|
val current = currentPosition() ?: return@withLock
|
||||||
@@ -198,9 +209,9 @@ class PlaybackController(
|
|||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
lastEdit =
|
lastEdit =
|
||||||
if (removed == null) {
|
if (removed == null) {
|
||||||
ReloadUndo(entries, cursor)
|
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
|
||||||
} else {
|
} else {
|
||||||
RemovedUndo(entries, cursor, target, position, removed)
|
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder)
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
position == current -> {
|
position == current -> {
|
||||||
@@ -240,7 +251,7 @@ class PlaybackController(
|
|||||||
val current = currentPosition() ?: return@withLock
|
val current = currentPosition() ?: return@withLock
|
||||||
val cursor = queueRepository.cursor()
|
val cursor = queueRepository.cursor()
|
||||||
val entries = snapshot.entries.map { it.entry }
|
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()
|
this.snapshot = queueRepository.snapshot()
|
||||||
val target = movedCursor(current, from, to)
|
val target = movedCursor(current, from, to)
|
||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
@@ -248,9 +259,9 @@ class PlaybackController(
|
|||||||
val toInWindow = to in windowStart..windowEnd
|
val toInWindow = to in windowStart..windowEnd
|
||||||
lastEdit =
|
lastEdit =
|
||||||
if (fromInWindow == toInWindow) {
|
if (fromInWindow == toInWindow) {
|
||||||
MovedUndo(entries, cursor, target, from, to)
|
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder)
|
||||||
} else {
|
} else {
|
||||||
ReloadUndo(entries, cursor)
|
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
fromInWindow && toInWindow -> {
|
fromInWindow && toInWindow -> {
|
||||||
@@ -290,7 +301,8 @@ class PlaybackController(
|
|||||||
val player = player ?: return@withLock
|
val player = player ?: return@withLock
|
||||||
val playing = player.playWhenReady
|
val playing = player.playWhenReady
|
||||||
val positionMs = player.currentPositionMs
|
val positionMs = player.currentPositionMs
|
||||||
queueRepository.replace(undo.entries)
|
queueRepository.replace(undo.entries, undo.shuffleOrder)
|
||||||
|
shuffleEnabled = undo.shuffleOrder != null
|
||||||
val restored = queueRepository.snapshot()
|
val restored = queueRepository.snapshot()
|
||||||
this.snapshot = restored
|
this.snapshot = restored
|
||||||
lastEdit = null
|
lastEdit = null
|
||||||
@@ -371,9 +383,71 @@ class PlaybackController(
|
|||||||
if (player.playWhenReady) player.pause() else player.play()
|
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) {
|
fun seekTo(positionMs: Long) {
|
||||||
player?.seekTo(positionMs)
|
player?.seekTo(positionMs)
|
||||||
@@ -413,6 +487,11 @@ class PlaybackController(
|
|||||||
lastDurationMs = 0
|
lastDurationMs = 0
|
||||||
lastError = null
|
lastError = null
|
||||||
lastEdit = null
|
lastEdit = null
|
||||||
|
endedHandled = false
|
||||||
|
val modes = queueRepository.modes()
|
||||||
|
shuffleEnabled = modes.shuffle
|
||||||
|
repeatMode = modes.repeat.toRepeatMode()
|
||||||
|
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
||||||
queueRepository.replace(entries)
|
queueRepository.replace(entries)
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
this.snapshot = snapshot
|
this.snapshot = snapshot
|
||||||
@@ -421,7 +500,14 @@ class PlaybackController(
|
|||||||
stopLocked()
|
stopLocked()
|
||||||
return@withLock
|
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)
|
queueRepository.setCursor(position)
|
||||||
loadWindow(position, autoplay = true)
|
loadWindow(position, autoplay = true)
|
||||||
}
|
}
|
||||||
@@ -431,6 +517,11 @@ class PlaybackController(
|
|||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
windowStart = 0
|
windowStart = 0
|
||||||
windowEnd = -1
|
windowEnd = -1
|
||||||
|
endedHandled = false
|
||||||
|
val modes = queueRepository.modes()
|
||||||
|
shuffleEnabled = modes.shuffle
|
||||||
|
repeatMode = modes.repeat.toRepeatMode()
|
||||||
|
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
this.snapshot = snapshot
|
this.snapshot = snapshot
|
||||||
val queueSourceId =
|
val queueSourceId =
|
||||||
@@ -492,22 +583,24 @@ class PlaybackController(
|
|||||||
refresh(position)
|
refresh(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun skip(delta: Long) {
|
private fun jumpTo(target: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val current = currentPosition() ?: return@launch
|
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
this@PlaybackController.snapshot = snapshot
|
this@PlaybackController.snapshot = snapshot
|
||||||
if (snapshot.size == 0L) return@launch
|
if (snapshot.size == 0L) return@launch
|
||||||
val position = current.coerceIn(0, snapshot.size - 1)
|
val current = currentPosition() ?: return@launch
|
||||||
val target = (position + delta).coerceIn(0, snapshot.size - 1)
|
val dest = target.coerceIn(0, snapshot.size - 1)
|
||||||
if (target == position) return@launch
|
if (dest == current) return@launch
|
||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
queueRepository.setCursor(target)
|
queueRepository.setCursor(dest)
|
||||||
shiftWindow(target)
|
shiftWindow(dest)
|
||||||
val player = player ?: return@launch
|
val player = player ?: return@launch
|
||||||
|
val wasEnded = player.isEnded
|
||||||
player.ensurePrepared()
|
player.ensurePrepared()
|
||||||
player.seekToIndex((target - windowStart).toInt())
|
player.seekToIndex((dest - windowStart).toInt())
|
||||||
refresh(target)
|
if (wasEnded) player.play()
|
||||||
|
endedHandled = false
|
||||||
|
refresh(dest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,11 +681,15 @@ class PlaybackController(
|
|||||||
object : PlayerHandle.Listener {
|
object : PlayerHandle.Listener {
|
||||||
override fun onTransition() {
|
override fun onTransition() {
|
||||||
lastError = null
|
lastError = null
|
||||||
|
endedHandled = false
|
||||||
if (updating) return
|
if (updating) return
|
||||||
scope.launch { onPositionChanged() }
|
scope.launch { onPositionChanged() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvents() = refresh()
|
override fun onEvents() {
|
||||||
|
if (player?.isEnded == true) handleEnded()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onError(message: String) {
|
override fun onError(message: String) {
|
||||||
lastError = message
|
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? {
|
private fun contextAt(position: Long?): QueueContext? {
|
||||||
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null
|
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null
|
||||||
return QueueContext(entry.kind, entry.refId)
|
return QueueContext(entry.kind, entry.refId)
|
||||||
@@ -626,7 +739,6 @@ class PlaybackController(
|
|||||||
private fun refresh(position: Long? = currentPosition()) {
|
private fun refresh(position: Long? = currentPosition()) {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
updateBuffering()
|
updateBuffering()
|
||||||
val size = snapshot?.size ?: 0
|
|
||||||
lastDurationMs = player.durationMs.takeIf { it > 0 } ?: lastDurationMs
|
lastDurationMs = player.durationMs.takeIf { it > 0 } ?: lastDurationMs
|
||||||
_state.value =
|
_state.value =
|
||||||
PlaybackState(
|
PlaybackState(
|
||||||
@@ -638,8 +750,10 @@ class PlaybackController(
|
|||||||
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
|
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
|
||||||
positionMs = player.currentPositionMs,
|
positionMs = player.currentPositionMs,
|
||||||
durationMs = lastDurationMs,
|
durationMs = lastDurationMs,
|
||||||
hasNext = position != null && position < size - 1,
|
hasNext = true,
|
||||||
hasPrevious = position != null && position > 0,
|
hasPrevious = true,
|
||||||
|
shuffle = shuffleEnabled,
|
||||||
|
repeat = repeatMode,
|
||||||
)
|
)
|
||||||
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
|
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
|
||||||
}
|
}
|
||||||
@@ -670,5 +784,6 @@ class PlaybackController(
|
|||||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||||
const val QUEUE_WINDOW_RADIUS = 25L
|
const val QUEUE_WINDOW_RADIUS = 25L
|
||||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
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 seekTo(positionMs: Long)
|
||||||
|
|
||||||
|
fun setRepeatOne(enabled: Boolean)
|
||||||
|
|
||||||
fun addListener(listener: Listener)
|
fun addListener(listener: Listener)
|
||||||
|
|
||||||
interface 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.automirrored.rounded.QueueMusic
|
||||||
import androidx.compose.material.icons.rounded.PauseCircle
|
import androidx.compose.material.icons.rounded.PauseCircle
|
||||||
import androidx.compose.material.icons.rounded.PlayCircle
|
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.SkipNext
|
||||||
import androidx.compose.material.icons.rounded.SkipPrevious
|
import androidx.compose.material.icons.rounded.SkipPrevious
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -57,6 +60,7 @@ import coil3.request.ImageRequest
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
|
import com.subtracks.playback.RepeatMode
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
@@ -91,6 +95,8 @@ fun NowPlayingRoute(
|
|||||||
onPlayPause = controller::togglePlayPause,
|
onPlayPause = controller::togglePlayPause,
|
||||||
onNext = controller::next,
|
onNext = controller::next,
|
||||||
onPrevious = controller::previous,
|
onPrevious = controller::previous,
|
||||||
|
onShuffle = controller::toggleShuffle,
|
||||||
|
onRepeat = controller::cycleRepeat,
|
||||||
onSeek = controller::seekTo,
|
onSeek = controller::seekTo,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -121,6 +127,8 @@ fun NowPlayingScreen(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
|
onShuffle: () -> Unit = {},
|
||||||
|
onRepeat: () -> Unit = {},
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
thumbnailRef: CoverArtRef? = null,
|
thumbnailRef: CoverArtRef? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -315,7 +323,8 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 56.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 56.dp),
|
||||||
horizontalArrangement = Arrangement.Start,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) {
|
IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -324,6 +333,41 @@ fun NowPlayingScreen(
|
|||||||
modifier = Modifier.size(30.dp),
|
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()
|
seedLibrary()
|
||||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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()))
|
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
||||||
}
|
}
|
||||||
@@ -296,7 +296,7 @@ class QueueRepositoryTest {
|
|||||||
seedLibrary()
|
seedLibrary()
|
||||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
|
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()))
|
assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot()))
|
||||||
}
|
}
|
||||||
@@ -307,8 +307,8 @@ class QueueRepositoryTest {
|
|||||||
seedLibrary()
|
seedLibrary()
|
||||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||||
|
|
||||||
assertFalse(repository.move(from = 5, to = 0))
|
assertFalse(repository.move(repository.snapshot(), from = 5, to = 0))
|
||||||
assertTrue(repository.move(from = 0, to = 2))
|
assertTrue(repository.move(repository.snapshot(), from = 0, to = 2))
|
||||||
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var duration = 0L
|
var duration = 0L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var positionMs = 0L
|
||||||
|
|
||||||
private val listeners = CopyOnWriteArrayList<PlayerHandle.Listener>()
|
private val listeners = CopyOnWriteArrayList<PlayerHandle.Listener>()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -54,7 +57,7 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
|
|
||||||
override val durationMs: Long get() = duration
|
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)
|
override val currentItem: QueueItem? get() = items.getOrNull(index)
|
||||||
|
|
||||||
@@ -190,6 +193,10 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
notifyEvents()
|
notifyEvents()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setRepeatOne(enabled: Boolean) {
|
||||||
|
operations += "setRepeatOne($enabled)"
|
||||||
|
}
|
||||||
|
|
||||||
override fun addListener(listener: PlayerHandle.Listener) {
|
override fun addListener(listener: PlayerHandle.Listener) {
|
||||||
listeners += listener
|
listeners += listener
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theLastTrackReportsNoNext() {
|
fun theLastTrackStillEnablesNext() {
|
||||||
seedAlbum(3, sourceId = 1)
|
seedAlbum(3, sourceId = 1)
|
||||||
|
|
||||||
controller.playAlbum(1, "al1", 2)
|
controller.playAlbum(1, "al1", 2)
|
||||||
@@ -178,7 +178,7 @@ class PlaybackControllerTest {
|
|||||||
?.id == "s3"
|
?.id == "s3"
|
||||||
}
|
}
|
||||||
|
|
||||||
assertFalse(controller.state.value.hasNext)
|
assertTrue(controller.state.value.hasNext)
|
||||||
assertTrue(controller.state.value.hasPrevious)
|
assertTrue(controller.state.value.hasPrevious)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@ class PlaybackControllerTest {
|
|||||||
assertEquals(QueueContext(QueueKind.Album, "al1"), controller.state.value.context)
|
assertEquals(QueueContext(QueueKind.Album, "al1"), controller.state.value.context)
|
||||||
assertTrue(controller.state.value.isPlaying)
|
assertTrue(controller.state.value.isPlaying)
|
||||||
assertTrue(controller.state.value.hasNext)
|
assertTrue(controller.state.value.hasNext)
|
||||||
assertFalse(controller.state.value.hasPrevious)
|
assertTrue(controller.state.value.hasPrevious)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -371,7 +371,7 @@ class PlaybackControllerTest {
|
|||||||
|
|
||||||
assertEquals(0L, controller.state.value.position)
|
assertEquals(0L, controller.state.value.position)
|
||||||
assertTrue(handle.operations.any { it.startsWith("setWindow") })
|
assertTrue(handle.operations.any { it.startsWith("setWindow") })
|
||||||
assertFalse(controller.state.value.hasPrevious)
|
assertTrue(controller.state.value.hasPrevious)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -595,6 +595,137 @@ class PlaybackControllerTest {
|
|||||||
assertEquals(listOf("s1", "s2", "s3"), handle.items.map { it.id })
|
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(
|
private fun seedAlbum(
|
||||||
count: Int,
|
count: Int,
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
Reference in New Issue
Block a user