mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
add media3, the playback session and the sql-backed queue
This commit is contained in:
@@ -113,6 +113,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.room3.paging)
|
implementation(libs.androidx.room3.paging)
|
||||||
implementation(libs.androidx.paging.compose)
|
implementation(libs.androidx.paging.compose)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
|
implementation(libs.androidx.media3.session)
|
||||||
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "c359cb54d1d99a2de8efbe1f0c8ad182",
|
||||||
|
"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": "app_settings",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `maxBitrateWifi` INTEGER NOT NULL, `maxBitrateMobile` INTEGER NOT NULL, `streamFormat` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "maxBitrateWifi",
|
||||||
|
"columnName": "maxBitrateWifi",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "maxBitrateMobile",
|
||||||
|
"columnName": "maxBitrateMobile",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "streamFormat",
|
||||||
|
"columnName": "streamFormat",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"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, 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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, 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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, 'c359cb54d1d99a2de8efbe1f0c8ad182')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 3,
|
||||||
|
"identityHash": "373e606ebb1b0c57ffa4faad216ab74f",
|
||||||
|
"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, 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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "queuePosition",
|
||||||
|
"columnName": "queuePosition",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, '373e606ebb1b0c57ffa4faad216ab74f')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".SubtracksApp"
|
android:name=".SubtracksApp"
|
||||||
@@ -25,6 +29,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".playback.PlaybackService"
|
||||||
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
package com.subtracks
|
package com.subtracks
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.subtracks.ui.SubtracksRoot
|
import com.subtracks.ui.SubtracksRoot
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val requestNotifications =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestNotificationPermission()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
@@ -17,4 +26,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
val granted =
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!granted) requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
|||||||
Room
|
Room
|
||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(BundledSQLiteDriver())
|
.setDriver(BundledSQLiteDriver())
|
||||||
|
.addMigrations(*MIGRATIONS)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import com.subtracks.data.model.Song
|
|||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
internal const val PLAYLIST_SONGS_SQL =
|
||||||
|
"SELECT songs.*, albums.coverArt AS coverArt FROM playlist_songs " +
|
||||||
|
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||||
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||||
|
"ORDER BY playlist_songs.position"
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||||
interface LibraryDao {
|
interface LibraryDao {
|
||||||
@@ -97,13 +104,7 @@ interface LibraryDao {
|
|||||||
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
|
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
|
||||||
fun playlists(sourceId: Long): PagingSource<Int, Playlist>
|
fun playlists(sourceId: Long): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query(
|
@Query(PLAYLIST_SONGS_SQL)
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM playlist_songs " +
|
|
||||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
|
||||||
"ORDER BY playlist_songs.position",
|
|
||||||
)
|
|
||||||
fun playlistSongs(
|
fun playlistSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.migration.Migration
|
||||||
|
import androidx.sqlite.SQLiteConnection
|
||||||
|
import androidx.sqlite.execSQL
|
||||||
|
|
||||||
|
val MIGRATION_1_2 =
|
||||||
|
object : Migration(1, 2) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("DROP TABLE IF EXISTS `app_settings`")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArt` TEXT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_2_3 =
|
||||||
|
object : Migration(2, 3) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `queue_entries` (`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 )",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `queue_entries` (`sourceId`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `queue_entries` (`position`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `playback_cursor` (`id` INTEGER NOT NULL, " +
|
||||||
|
"`queuePosition` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.Dao
|
||||||
|
import androidx.room3.Insert
|
||||||
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Upsert
|
||||||
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
|
import com.subtracks.data.model.QueueEntry
|
||||||
|
import com.subtracks.data.model.SongListItem
|
||||||
|
|
||||||
|
private const val ALBUM_SONGS_SQL =
|
||||||
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
|
||||||
|
"ORDER BY songs.disc, songs.track"
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface QueueDao {
|
||||||
|
@Query("DELETE FROM queue_entries")
|
||||||
|
suspend fun clear()
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(entries: List<QueueEntry>)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM queue_entries ORDER BY position, id")
|
||||||
|
suspend fun entries(): List<QueueEntry>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||||
|
suspend fun cursor(): PlaybackCursor?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun setCursor(cursor: PlaybackCursor)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT COUNT(*) FROM playlist_songs " +
|
||||||
|
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||||
|
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId",
|
||||||
|
)
|
||||||
|
suspend fun playlistLength(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND albumId = :albumId")
|
||||||
|
suspend fun albumLength(
|
||||||
|
sourceId: Long,
|
||||||
|
albumId: String,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND id = :songId")
|
||||||
|
suspend fun songLength(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
@Query("$PLAYLIST_SONGS_SQL LIMIT 1 OFFSET :offset")
|
||||||
|
suspend fun playlistSongAt(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
offset: Long,
|
||||||
|
): SongListItem?
|
||||||
|
|
||||||
|
@Query("$ALBUM_SONGS_SQL LIMIT 1 OFFSET :offset")
|
||||||
|
suspend fun albumSongAt(
|
||||||
|
sourceId: Long,
|
||||||
|
albumId: String,
|
||||||
|
offset: Long,
|
||||||
|
): SongListItem?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.id = :songId LIMIT 1",
|
||||||
|
)
|
||||||
|
suspend fun song(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
): SongListItem?
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.ColumnTypeConverters
|
||||||
import androidx.room3.Database
|
import androidx.room3.Database
|
||||||
import androidx.room3.RoomDatabase
|
import androidx.room3.RoomDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
|
import com.subtracks.data.model.QueueEntry
|
||||||
|
import com.subtracks.data.model.QueueKindConverter
|
||||||
import com.subtracks.data.model.SearchIndex
|
import com.subtracks.data.model.SearchIndex
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
@@ -21,14 +25,19 @@ import com.subtracks.data.model.SubsonicSource
|
|||||||
PlaylistSong::class,
|
PlaylistSong::class,
|
||||||
Song::class,
|
Song::class,
|
||||||
SearchIndex::class,
|
SearchIndex::class,
|
||||||
|
QueueEntry::class,
|
||||||
|
PlaybackCursor::class,
|
||||||
],
|
],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
|
@ColumnTypeConverters(QueueKindConverter::class)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|
||||||
abstract fun sourcesDao(): SourcesDao
|
abstract fun sourcesDao(): SourcesDao
|
||||||
|
|
||||||
abstract fun searchDao(): SearchDao
|
abstract fun searchDao(): SearchDao
|
||||||
|
|
||||||
|
abstract fun queueDao(): QueueDao
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.data.model
|
package com.subtracks.data.model
|
||||||
|
|
||||||
import androidx.room3.ColumnInfo
|
import androidx.room3.ColumnInfo
|
||||||
|
import androidx.room3.ColumnTypeConverter
|
||||||
import androidx.room3.Embedded
|
import androidx.room3.Embedded
|
||||||
import androidx.room3.Entity
|
import androidx.room3.Entity
|
||||||
import androidx.room3.ForeignKey
|
import androidx.room3.ForeignKey
|
||||||
@@ -173,3 +174,45 @@ data class SearchIndex(
|
|||||||
val itemId: String,
|
val itemId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class QueueKind { Playlist, Album, Song }
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "queue_entries",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId"), Index("position")],
|
||||||
|
)
|
||||||
|
data class QueueEntry(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val position: Long,
|
||||||
|
val sourceId: Long,
|
||||||
|
val kind: QueueKind,
|
||||||
|
val refId: String,
|
||||||
|
val rangeStart: Long? = null,
|
||||||
|
val rangeEnd: Long? = null,
|
||||||
|
) {
|
||||||
|
val offset: Long get() = rangeStart ?: 0
|
||||||
|
|
||||||
|
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity(tableName = "playback_cursor")
|
||||||
|
data class PlaybackCursor(
|
||||||
|
@PrimaryKey val id: Long = 1,
|
||||||
|
val queuePosition: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
class QueueKindConverter {
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun fromQueueKind(kind: QueueKind): String = kind.name
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun toQueueKind(value: String): QueueKind = QueueKind.valueOf(value)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
|
import androidx.room3.immediateTransaction
|
||||||
|
import androidx.room3.useWriterConnection
|
||||||
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
|
import com.subtracks.data.model.QueueEntry
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.SongListItem
|
||||||
|
|
||||||
|
data class ResolvedQueueEntry(
|
||||||
|
val entry: QueueEntry,
|
||||||
|
val length: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class QueueSnapshot(
|
||||||
|
val entries: List<ResolvedQueueEntry>,
|
||||||
|
) {
|
||||||
|
val size: Long = entries.sumOf { it.length }
|
||||||
|
|
||||||
|
fun locate(position: Long): Pair<QueueEntry, Long>? {
|
||||||
|
var remaining = position
|
||||||
|
for (resolved in entries) {
|
||||||
|
if (remaining < resolved.length) return resolved.entry to remaining
|
||||||
|
remaining -= resolved.length
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class QueueWindowItem(
|
||||||
|
val position: Long,
|
||||||
|
val item: SongListItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
class QueueRepository(
|
||||||
|
private val db: SubtracksDatabase,
|
||||||
|
) {
|
||||||
|
private val dao get() = db.queueDao()
|
||||||
|
|
||||||
|
fun playlistEntry(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Playlist, refId = playlistId)
|
||||||
|
|
||||||
|
fun albumEntry(
|
||||||
|
sourceId: Long,
|
||||||
|
albumId: String,
|
||||||
|
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Album, refId = albumId)
|
||||||
|
|
||||||
|
fun songEntry(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId)
|
||||||
|
|
||||||
|
suspend fun replace(entries: List<QueueEntry>) {
|
||||||
|
db.useWriterConnection { transactor ->
|
||||||
|
transactor.immediateTransaction {
|
||||||
|
dao.clear()
|
||||||
|
dao.insert(entries)
|
||||||
|
dao.setCursor(PlaybackCursor(queuePosition = 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun snapshot(): QueueSnapshot = QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
|
||||||
|
|
||||||
|
suspend fun itemAt(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
position: Long,
|
||||||
|
): SongListItem? {
|
||||||
|
val (entry, offset) = snapshot.locate(position) ?: return null
|
||||||
|
return itemWithin(entry, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun window(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
center: Long,
|
||||||
|
radius: Long,
|
||||||
|
): List<QueueWindowItem> {
|
||||||
|
if (snapshot.size == 0L) return emptyList()
|
||||||
|
val first = (center - radius).coerceAtLeast(0)
|
||||||
|
val last = (center + radius).coerceAtMost(snapshot.size - 1)
|
||||||
|
return (first..last).mapNotNull { position ->
|
||||||
|
itemAt(snapshot, position)?.let { QueueWindowItem(position, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
||||||
|
|
||||||
|
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
|
||||||
|
|
||||||
|
private suspend fun itemWithin(
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
): SongListItem? {
|
||||||
|
val index = entry.offset + offset
|
||||||
|
return when (entry.kind) {
|
||||||
|
QueueKind.Playlist -> dao.playlistSongAt(entry.sourceId, entry.refId, index)
|
||||||
|
QueueKind.Album -> dao.albumSongAt(entry.sourceId, entry.refId, index)
|
||||||
|
QueueKind.Song -> if (offset == 0L) dao.song(entry.sourceId, entry.refId) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||||
|
val total =
|
||||||
|
when (kind) {
|
||||||
|
QueueKind.Song -> dao.songLength(sourceId, refId)
|
||||||
|
QueueKind.Playlist -> dao.playlistLength(sourceId, refId)
|
||||||
|
QueueKind.Album -> dao.albumLength(sourceId, refId)
|
||||||
|
}
|
||||||
|
val available = (total - offset).coerceAtLeast(0)
|
||||||
|
return count?.coerceAtMost(available) ?: available
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.subtracks.data.sync.SyncService
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -34,10 +35,16 @@ class SourceRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
fun sources(): Flow<List<Source>> = db.sourcesDao().sources()
|
fun sources(): Flow<List<Source>> = db.sourcesDao().sources()
|
||||||
|
|
||||||
fun activeSourceId(): Flow<Long?> = db.sourcesDao().activeSourceId()
|
fun activeSourceId(): Flow<Long?> = db.sourcesDao().activeSourceId()
|
||||||
|
|
||||||
|
suspend fun activeSourceIdOnce(): Long? = db.sourcesDao().activeSourceIdOnce()
|
||||||
|
|
||||||
fun activeConfig(): Flow<SubsonicConfig?> = db.sourcesDao().activeSubsonicConfig()
|
fun activeConfig(): Flow<SubsonicConfig?> = db.sourcesDao().activeSubsonicConfig()
|
||||||
|
|
||||||
fun coverArt(
|
fun coverArt(
|
||||||
@@ -49,6 +56,8 @@ class SourceRepository(
|
|||||||
return CoverArtRef(url = url, cacheKey = "${source.id}:$coverArt:$thumbnail")
|
return CoverArtRef(url = url, cacheKey = "${source.id}:$coverArt:$thumbnail")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun streamUri(songId: String): String? = active?.streamUri(songId)?.toString()
|
||||||
|
|
||||||
suspend fun addSource(
|
suspend fun addSource(
|
||||||
name: String,
|
name: String,
|
||||||
address: String,
|
address: String,
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class SubsonicSource(
|
|||||||
"stream",
|
"stream",
|
||||||
buildMap {
|
buildMap {
|
||||||
put("id", songId)
|
put("id", songId)
|
||||||
put("estimateContentLength", "true")
|
val transcodes = maxBitrate > 0 || !streamFormat.isNullOrEmpty()
|
||||||
|
if (transcodes) put("estimateContentLength", "true")
|
||||||
if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString())
|
if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString())
|
||||||
streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) }
|
streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import android.content.Context
|
|||||||
import com.subtracks.data.db.createAndroidDatabase
|
import com.subtracks.data.db.createAndroidDatabase
|
||||||
import com.subtracks.data.prefs.createUserPreferences
|
import com.subtracks.data.prefs.createUserPreferences
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
|
import com.subtracks.playback.MediaSessionConnection
|
||||||
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.playback.PlayerConnection
|
||||||
import com.subtracks.ui.RootViewModel
|
import com.subtracks.ui.RootViewModel
|
||||||
import com.subtracks.ui.library.AlbumDetailViewModel
|
import com.subtracks.ui.library.AlbumDetailViewModel
|
||||||
import com.subtracks.ui.library.LibraryViewModel
|
import com.subtracks.ui.library.LibraryViewModel
|
||||||
@@ -26,10 +30,13 @@ fun appModule(
|
|||||||
single { SourceRepository(get(), get()) }
|
single { SourceRepository(get(), get()) }
|
||||||
single { LibraryRepository(get(), get()) }
|
single { LibraryRepository(get(), get()) }
|
||||||
single { SyncManager(get()) }
|
single { SyncManager(get()) }
|
||||||
|
single { QueueRepository(get()) }
|
||||||
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
||||||
|
single { PlaybackController(get(), get(), get()) }
|
||||||
viewModel { RootViewModel(get()) }
|
viewModel { RootViewModel(get()) }
|
||||||
viewModel { LibraryViewModel(get(), get(), get(), get()) }
|
viewModel { LibraryViewModel(get(), get(), get(), get()) }
|
||||||
viewModel { SettingsViewModel(get()) }
|
viewModel { SettingsViewModel(get()) }
|
||||||
viewModel { AddSourceViewModel(get(), get()) }
|
viewModel { AddSourceViewModel(get(), get()) }
|
||||||
viewModel { params -> AlbumDetailViewModel(get(), get(), params.get()) }
|
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
|
||||||
viewModel { params -> PlaylistDetailViewModel(get(), get(), params.get()) }
|
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
|
||||||
|
class Media3PlayerHandle(
|
||||||
|
private val controller: MediaController,
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
) : PlayerHandle {
|
||||||
|
override val itemCount: Int get() = controller.mediaItemCount
|
||||||
|
|
||||||
|
override val currentIndex: Int get() = controller.currentMediaItemIndex
|
||||||
|
|
||||||
|
override val isIdle: Boolean get() = controller.playbackState == Player.STATE_IDLE
|
||||||
|
|
||||||
|
override val isEnded: Boolean get() = controller.playbackState == Player.STATE_ENDED
|
||||||
|
|
||||||
|
override val isBuffering: Boolean get() = controller.playbackState == Player.STATE_BUFFERING
|
||||||
|
|
||||||
|
override val isPlaying: Boolean get() = controller.isPlaying
|
||||||
|
|
||||||
|
override val playWhenReady: Boolean get() = controller.playWhenReady
|
||||||
|
|
||||||
|
override val durationMs: Long
|
||||||
|
get() = controller.duration.takeIf { it != C.TIME_UNSET } ?: 0
|
||||||
|
|
||||||
|
override val currentPositionMs: Long get() = controller.currentPosition.coerceAtLeast(0)
|
||||||
|
|
||||||
|
override val currentItem: QueueItem? get() = controller.currentMediaItem?.let(::toQueueItem)
|
||||||
|
|
||||||
|
override fun setWindow(
|
||||||
|
items: List<QueueItem>,
|
||||||
|
startIndex: Int,
|
||||||
|
) = controller.setMediaItems(items.map(::toMediaItem), startIndex, 0)
|
||||||
|
|
||||||
|
override fun addFirst(item: QueueItem) = controller.addMediaItems(0, listOf(toMediaItem(item)))
|
||||||
|
|
||||||
|
override fun addLast(item: QueueItem) = controller.addMediaItems(controller.mediaItemCount, listOf(toMediaItem(item)))
|
||||||
|
|
||||||
|
override fun removeFirst() = controller.removeMediaItem(0)
|
||||||
|
|
||||||
|
override fun removeLast() = controller.removeMediaItem(controller.mediaItemCount - 1)
|
||||||
|
|
||||||
|
override fun prepare() = controller.prepare()
|
||||||
|
|
||||||
|
override fun play() = controller.play()
|
||||||
|
|
||||||
|
override fun pause() = controller.pause()
|
||||||
|
|
||||||
|
override fun stop() = controller.stop()
|
||||||
|
|
||||||
|
override fun clear() = controller.clearMediaItems()
|
||||||
|
|
||||||
|
override fun seekToIndex(index: Int) {
|
||||||
|
Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
|
||||||
|
controller.seekTo(index, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekTo(positionMs: Long) {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
||||||
|
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||||
|
)
|
||||||
|
controller.seekTo(positionMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addListener(listener: PlayerHandle.Listener) {
|
||||||
|
controller.addListener(
|
||||||
|
object : Player.Listener {
|
||||||
|
override fun onMediaItemTransition(
|
||||||
|
mediaItem: MediaItem?,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
||||||
|
listener.onTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvents(
|
||||||
|
player: Player,
|
||||||
|
events: Player.Events,
|
||||||
|
) = listener.onEvents()
|
||||||
|
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
listener.onError(error.errorCodeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"state=$playbackState position=${controller.currentPosition} " +
|
||||||
|
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||||
|
val metadata =
|
||||||
|
MediaMetadata
|
||||||
|
.Builder()
|
||||||
|
.setTitle(item.title)
|
||||||
|
.setArtist(item.artist)
|
||||||
|
.setAlbumTitle(item.album)
|
||||||
|
.setArtworkUri(sourceRepository.coverArt(item.coverArtId, true)?.url?.toUri())
|
||||||
|
.setIsBrowsable(false)
|
||||||
|
.setIsPlayable(true)
|
||||||
|
.setExtras(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||||
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||||
|
},
|
||||||
|
).build()
|
||||||
|
return MediaItem
|
||||||
|
.Builder()
|
||||||
|
.setMediaId(item.id)
|
||||||
|
.setUri(sourceRepository.streamUri(item.id))
|
||||||
|
.setMediaMetadata(metadata)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
||||||
|
QueueItem(
|
||||||
|
id = mediaItem.mediaId,
|
||||||
|
title =
|
||||||
|
mediaItem.mediaMetadata.title
|
||||||
|
?.toString()
|
||||||
|
.orEmpty(),
|
||||||
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||||
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||||
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||||
|
durationMs =
|
||||||
|
mediaItem.mediaMetadata.extras
|
||||||
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "SubtracksPlayback"
|
||||||
|
const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||||
|
const val EXTRA_DURATION_MS = "durationMs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.QueueEntry
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.repo.QueueRepository
|
||||||
|
import com.subtracks.data.repo.QueueSnapshot
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
data class QueueItem(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val artist: String?,
|
||||||
|
val album: String?,
|
||||||
|
val coverArtId: String?,
|
||||||
|
val durationMs: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class QueueContext(
|
||||||
|
val kind: QueueKind,
|
||||||
|
val refId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PlaybackState(
|
||||||
|
val item: QueueItem? = null,
|
||||||
|
val context: QueueContext? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val isBuffering: Boolean = false,
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
val positionMs: Long = 0,
|
||||||
|
val durationMs: Long = 0,
|
||||||
|
val hasNext: Boolean = false,
|
||||||
|
val hasPrevious: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class PlaybackController(
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
private val queueRepository: QueueRepository,
|
||||||
|
private val connection: PlayerConnection,
|
||||||
|
dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
private val _state = MutableStateFlow(PlaybackState())
|
||||||
|
val state: StateFlow<PlaybackState> = _state
|
||||||
|
|
||||||
|
private var player: PlayerHandle? = null
|
||||||
|
private var connecting = false
|
||||||
|
private var pendingPlay: Pair<List<QueueEntry>, Long>? = null
|
||||||
|
private var positionJob: Job? = null
|
||||||
|
private var windowJob: Job? = null
|
||||||
|
private var bufferingJob: Job? = null
|
||||||
|
private var showBuffering = false
|
||||||
|
private val startLock = Mutex()
|
||||||
|
|
||||||
|
private var snapshot: QueueSnapshot? = null
|
||||||
|
private var lastDurationMs = 0L
|
||||||
|
private var lastError: String? = null
|
||||||
|
private var queueSourceId: Long? = null
|
||||||
|
private var windowStart = 0L
|
||||||
|
private var windowEnd = -1L
|
||||||
|
private var updating = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
sourceRepository.activeSourceId().collect { sourceId ->
|
||||||
|
if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
if (player != null || connecting) return
|
||||||
|
connecting = true
|
||||||
|
connection.connect(
|
||||||
|
onConnected = { handle ->
|
||||||
|
connecting = false
|
||||||
|
player = handle
|
||||||
|
handle.addListener(playerListener)
|
||||||
|
scope.launch {
|
||||||
|
val pending = pendingPlay
|
||||||
|
pendingPlay = null
|
||||||
|
if (pending != null) start(pending.first, pending.second) else restore()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDisconnected = {
|
||||||
|
player = null
|
||||||
|
connecting = false
|
||||||
|
scope.launch { stop() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playAlbum(
|
||||||
|
sourceId: Long,
|
||||||
|
albumId: String,
|
||||||
|
startOrdinal: Long,
|
||||||
|
) = play(listOf(queueRepository.albumEntry(sourceId, albumId)), startOrdinal)
|
||||||
|
|
||||||
|
fun playPlaylist(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
startPosition: Long,
|
||||||
|
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), startPosition)
|
||||||
|
|
||||||
|
fun togglePlayPause() {
|
||||||
|
val player = player ?: return
|
||||||
|
if (player.isEnded) {
|
||||||
|
player.seekToIndex(player.currentIndex)
|
||||||
|
player.play()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
player.ensurePrepared()
|
||||||
|
if (player.playWhenReady) player.pause() else player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun next() = skip(1)
|
||||||
|
|
||||||
|
fun previous() = skip(-1)
|
||||||
|
|
||||||
|
fun seekTo(positionMs: Long) {
|
||||||
|
player?.seekTo(positionMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun coverArt(
|
||||||
|
item: QueueItem?,
|
||||||
|
thumbnail: Boolean = false,
|
||||||
|
): CoverArtRef? = sourceRepository.coverArt(item?.coverArtId, thumbnail)
|
||||||
|
|
||||||
|
private fun play(
|
||||||
|
entries: List<QueueEntry>,
|
||||||
|
startPosition: Long,
|
||||||
|
) {
|
||||||
|
if (player == null) {
|
||||||
|
pendingPlay = entries to startPosition
|
||||||
|
connect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch { start(entries, startPosition) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun start(
|
||||||
|
entries: List<QueueEntry>,
|
||||||
|
startPosition: Long,
|
||||||
|
) = startLock.withLock {
|
||||||
|
windowJob?.cancel()
|
||||||
|
windowStart = 0
|
||||||
|
windowEnd = -1
|
||||||
|
lastDurationMs = 0
|
||||||
|
lastError = null
|
||||||
|
queueRepository.replace(entries)
|
||||||
|
val snapshot = queueRepository.snapshot()
|
||||||
|
this.snapshot = snapshot
|
||||||
|
queueSourceId = entries.firstOrNull()?.sourceId
|
||||||
|
if (snapshot.size == 0L) {
|
||||||
|
stopLocked()
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
val position = startPosition.coerceIn(0, snapshot.size - 1)
|
||||||
|
queueRepository.setCursor(position)
|
||||||
|
loadWindow(position, autoplay = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun restore() =
|
||||||
|
startLock.withLock {
|
||||||
|
windowJob?.cancel()
|
||||||
|
windowStart = 0
|
||||||
|
windowEnd = -1
|
||||||
|
val snapshot = queueRepository.snapshot()
|
||||||
|
this.snapshot = snapshot
|
||||||
|
val queueSourceId =
|
||||||
|
snapshot.entries
|
||||||
|
.firstOrNull()
|
||||||
|
?.entry
|
||||||
|
?.sourceId
|
||||||
|
val activeId = sourceRepository.activeSourceIdOnce()
|
||||||
|
if (snapshot.size == 0L || (queueSourceId != null && activeId != null && queueSourceId != activeId)) {
|
||||||
|
stopLocked()
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
this.queueSourceId = queueSourceId
|
||||||
|
val position = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
|
||||||
|
loadWindow(position, autoplay = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stop() = startLock.withLock { stopLocked() }
|
||||||
|
|
||||||
|
private fun stopLocked() {
|
||||||
|
windowJob?.cancel()
|
||||||
|
bufferingJob?.cancel()
|
||||||
|
bufferingJob = null
|
||||||
|
showBuffering = false
|
||||||
|
snapshot = null
|
||||||
|
lastDurationMs = 0
|
||||||
|
lastError = null
|
||||||
|
queueSourceId = null
|
||||||
|
windowStart = 0
|
||||||
|
windowEnd = -1
|
||||||
|
player?.run {
|
||||||
|
stop()
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
_state.value = PlaybackState()
|
||||||
|
stopPositionTicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadWindow(
|
||||||
|
position: Long,
|
||||||
|
autoplay: Boolean,
|
||||||
|
) {
|
||||||
|
val player = player ?: return
|
||||||
|
val snapshot = snapshot ?: return
|
||||||
|
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
||||||
|
val startIndex = window.indexOfFirst { it.position == position }
|
||||||
|
if (startIndex < 0) return
|
||||||
|
windowStart = window.first().position
|
||||||
|
windowEnd = window.last().position
|
||||||
|
updating = true
|
||||||
|
player.setWindow(window.map { it.item.toQueueItem() }, startIndex)
|
||||||
|
updating = false
|
||||||
|
if (autoplay) {
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
refresh(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skip(delta: 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
|
||||||
|
windowJob?.cancel()
|
||||||
|
queueRepository.setCursor(target)
|
||||||
|
shiftWindow(target)
|
||||||
|
val player = player ?: return@launch
|
||||||
|
player.ensurePrepared()
|
||||||
|
player.seekToIndex((target - windowStart).toInt())
|
||||||
|
refresh(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun onPositionChanged() {
|
||||||
|
val player = player ?: return
|
||||||
|
val snapshot = snapshot ?: return
|
||||||
|
if (snapshot.size == 0L) return
|
||||||
|
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
|
||||||
|
queueRepository.setCursor(position)
|
||||||
|
refresh(position)
|
||||||
|
scheduleWindowShift(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleWindowShift(center: Long) {
|
||||||
|
windowJob?.cancel()
|
||||||
|
windowJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(WINDOW_SHIFT_DELAY_MS)
|
||||||
|
shiftWindow(center)
|
||||||
|
refresh(center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun shiftWindow(center: Long) =
|
||||||
|
startLock.withLock {
|
||||||
|
val player = player ?: return@withLock
|
||||||
|
val snapshot = queueRepository.snapshot()
|
||||||
|
this.snapshot = snapshot
|
||||||
|
if (snapshot.size == 0L) return@withLock
|
||||||
|
val target = center.coerceIn(0, snapshot.size - 1)
|
||||||
|
val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0)
|
||||||
|
val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1)
|
||||||
|
updating = true
|
||||||
|
try {
|
||||||
|
while (windowStart > desiredStart) {
|
||||||
|
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
|
||||||
|
player.addFirst(item.toQueueItem())
|
||||||
|
windowStart--
|
||||||
|
}
|
||||||
|
while (windowStart < desiredStart) {
|
||||||
|
player.removeFirst()
|
||||||
|
windowStart++
|
||||||
|
}
|
||||||
|
while (windowEnd < desiredEnd) {
|
||||||
|
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
|
||||||
|
player.addLast(item.toQueueItem())
|
||||||
|
windowEnd++
|
||||||
|
}
|
||||||
|
while (windowEnd > desiredEnd) {
|
||||||
|
player.removeLast()
|
||||||
|
windowEnd--
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
updating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentPosition(): Long? {
|
||||||
|
val player = player ?: return null
|
||||||
|
if (player.itemCount == 0) return null
|
||||||
|
return windowStart + player.currentIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private val playerListener =
|
||||||
|
object : PlayerHandle.Listener {
|
||||||
|
override fun onTransition() {
|
||||||
|
lastError = null
|
||||||
|
if (updating) return
|
||||||
|
scope.launch { onPositionChanged() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvents() = refresh()
|
||||||
|
|
||||||
|
override fun onError(message: String) {
|
||||||
|
lastError = message
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contextAt(position: Long?): QueueContext? {
|
||||||
|
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null
|
||||||
|
return QueueContext(entry.kind, entry.refId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBuffering() {
|
||||||
|
if (player?.isBuffering != true) {
|
||||||
|
bufferingJob?.cancel()
|
||||||
|
bufferingJob = null
|
||||||
|
showBuffering = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (showBuffering || bufferingJob?.isActive == true) return
|
||||||
|
bufferingJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||||
|
if (player?.isBuffering == true) {
|
||||||
|
showBuffering = true
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
item = player.currentItem,
|
||||||
|
context = contextAt(position),
|
||||||
|
error = lastError,
|
||||||
|
isBuffering = showBuffering,
|
||||||
|
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
|
||||||
|
positionMs = player.currentPositionMs,
|
||||||
|
durationMs = lastDurationMs,
|
||||||
|
hasNext = position != null && position < size - 1,
|
||||||
|
hasPrevious = position != null && position > 0,
|
||||||
|
)
|
||||||
|
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPositionTicker() {
|
||||||
|
if (positionJob?.isActive == true) return
|
||||||
|
positionJob =
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(POSITION_TICK_MS)
|
||||||
|
val player = player ?: break
|
||||||
|
_state.value = _state.value.copy(positionMs = player.currentPositionMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPositionTicker() {
|
||||||
|
positionJob?.cancel()
|
||||||
|
positionJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PlayerHandle.ensurePrepared() {
|
||||||
|
if (isIdle) prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val POSITION_TICK_MS = 500L
|
||||||
|
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||||
|
const val QUEUE_WINDOW_RADIUS = 25L
|
||||||
|
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import com.subtracks.MainActivity
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
class PlaybackService : MediaSessionService() {
|
||||||
|
private var session: MediaSession? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
val player =
|
||||||
|
ExoPlayer
|
||||||
|
.Builder(this)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes
|
||||||
|
.Builder()
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.build(),
|
||||||
|
true,
|
||||||
|
).setHandleAudioBecomingNoisy(true)
|
||||||
|
.setMediaSourceFactory(
|
||||||
|
DefaultMediaSourceFactory(this).setDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
|
||||||
|
).build()
|
||||||
|
session =
|
||||||
|
MediaSession
|
||||||
|
.Builder(this, player)
|
||||||
|
.setSessionActivity(
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
pauseAllPlayersAndStopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamingClient(): OkHttpClient =
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val started = SystemClock.elapsedRealtime()
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
Log.d(
|
||||||
|
STREAM_TAG,
|
||||||
|
"stream range=${chain.request().header("Range")} code=${response.code} " +
|
||||||
|
"contentRange=${response.header("Content-Range")} " +
|
||||||
|
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
||||||
|
)
|
||||||
|
response
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STREAM_TAG = "SubtracksPlayback"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
session?.run {
|
||||||
|
player.release()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
session = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
|
import androidx.media3.session.SessionToken
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
|
interface PlayerConnection {
|
||||||
|
fun connect(
|
||||||
|
onConnected: (PlayerHandle) -> Unit,
|
||||||
|
onDisconnected: () -> Unit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaSessionConnection(
|
||||||
|
private val context: Context,
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
) : PlayerConnection {
|
||||||
|
override fun connect(
|
||||||
|
onConnected: (PlayerHandle) -> Unit,
|
||||||
|
onDisconnected: () -> Unit,
|
||||||
|
) {
|
||||||
|
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||||
|
val future =
|
||||||
|
MediaController
|
||||||
|
.Builder(context, token)
|
||||||
|
.setListener(
|
||||||
|
object : MediaController.Listener {
|
||||||
|
override fun onDisconnected(controller: MediaController) = onDisconnected()
|
||||||
|
},
|
||||||
|
).buildAsync()
|
||||||
|
future.addListener(
|
||||||
|
{
|
||||||
|
val controller = runCatching { future.get() }.getOrNull()
|
||||||
|
if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository))
|
||||||
|
},
|
||||||
|
Executor { it.run() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
interface PlayerHandle {
|
||||||
|
val itemCount: Int
|
||||||
|
val currentIndex: Int
|
||||||
|
val isIdle: Boolean
|
||||||
|
val isEnded: Boolean
|
||||||
|
val isBuffering: Boolean
|
||||||
|
val isPlaying: Boolean
|
||||||
|
val playWhenReady: Boolean
|
||||||
|
val durationMs: Long
|
||||||
|
val currentPositionMs: Long
|
||||||
|
val currentItem: QueueItem?
|
||||||
|
|
||||||
|
fun setWindow(
|
||||||
|
items: List<QueueItem>,
|
||||||
|
startIndex: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun addFirst(item: QueueItem)
|
||||||
|
|
||||||
|
fun addLast(item: QueueItem)
|
||||||
|
|
||||||
|
fun removeFirst()
|
||||||
|
|
||||||
|
fun removeLast()
|
||||||
|
|
||||||
|
fun prepare()
|
||||||
|
|
||||||
|
fun play()
|
||||||
|
|
||||||
|
fun pause()
|
||||||
|
|
||||||
|
fun stop()
|
||||||
|
|
||||||
|
fun clear()
|
||||||
|
|
||||||
|
fun seekToIndex(index: Int)
|
||||||
|
|
||||||
|
fun seekTo(positionMs: Long)
|
||||||
|
|
||||||
|
fun addListener(listener: Listener)
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
fun onTransition()
|
||||||
|
|
||||||
|
fun onEvents()
|
||||||
|
|
||||||
|
fun onError(message: String) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.SongListItem
|
||||||
|
|
||||||
|
fun SongListItem.toQueueItem() =
|
||||||
|
QueueItem(
|
||||||
|
id = song.id,
|
||||||
|
title = song.title,
|
||||||
|
artist = song.artist,
|
||||||
|
album = song.album,
|
||||||
|
coverArtId = coverArt,
|
||||||
|
durationMs = song.duration?.let { it * 1000 },
|
||||||
|
)
|
||||||
@@ -14,6 +14,7 @@ navigationCompose = "2.10.1"
|
|||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
coil = "3.6.3"
|
coil = "3.6.3"
|
||||||
koin = "4.2.2"
|
koin = "4.2.2"
|
||||||
|
media3 = "1.11.1"
|
||||||
okhttp = "5.5.0"
|
okhttp = "5.5.0"
|
||||||
ktlint = "1.8.0"
|
ktlint = "1.8.0"
|
||||||
ktlintGradle = "14.2.0"
|
ktlintGradle = "14.2.0"
|
||||||
@@ -47,6 +48,9 @@ androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", v
|
|||||||
androidx-room3-sqlite-wrapper = { group = "androidx.room3", name = "room3-sqlite-wrapper", version.ref = "room3" }
|
androidx-room3-sqlite-wrapper = { group = "androidx.room3", name = "room3-sqlite-wrapper", version.ref = "room3" }
|
||||||
androidx-room3-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" }
|
androidx-room3-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" }
|
||||||
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
|
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
|
||||||
|
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
|
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
|
||||||
|
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||||
|
|||||||
Reference in New Issue
Block a user