diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6ffb2c84..bdba4610 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -113,6 +113,9 @@ dependencies {
implementation(libs.androidx.room3.paging)
implementation(libs.androidx.paging.compose)
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.coil.compose)
implementation(libs.coil.network.okhttp)
diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json
new file mode 100644
index 00000000..b9944d66
--- /dev/null
+++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/3.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/3.json
new file mode 100644
index 00000000..7633f689
--- /dev/null
+++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/3.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1f697d0a..9abb4be9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/subtracks/MainActivity.kt b/app/src/main/kotlin/com/subtracks/MainActivity.kt
index 86480a5c..5b933f52 100644
--- a/app/src/main/kotlin/com/subtracks/MainActivity.kt
+++ b/app/src/main/kotlin/com/subtracks/MainActivity.kt
@@ -1,15 +1,24 @@
package com.subtracks
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
import com.subtracks.ui.SubtracksRoot
import com.subtracks.ui.theme.SubtracksTheme
class MainActivity : ComponentActivity() {
+ private val requestNotifications =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ requestNotificationPermission()
enableEdgeToEdge()
setContent {
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)
+ }
}
diff --git a/app/src/main/kotlin/com/subtracks/data/db/Databases.kt b/app/src/main/kotlin/com/subtracks/data/db/Databases.kt
index d19f2d20..a499eb49 100644
--- a/app/src/main/kotlin/com/subtracks/data/db/Databases.kt
+++ b/app/src/main/kotlin/com/subtracks/data/db/Databases.kt
@@ -8,4 +8,5 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase =
Room
.databaseBuilder(context, "subtracks.db")
.setDriver(BundledSQLiteDriver())
+ .addMigrations(*MIGRATIONS)
.build()
diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt
index 0a569887..6c2630f6 100644
--- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt
+++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt
@@ -14,6 +14,13 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
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
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface LibraryDao {
@@ -97,13 +104,7 @@ interface LibraryDao {
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun playlists(sourceId: Long): PagingSource
- @Query(
- "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",
- )
+ @Query(PLAYLIST_SONGS_SQL)
fun playlistSongs(
sourceId: Long,
playlistId: String,
diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt
new file mode 100644
index 00000000..20355203
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt
@@ -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 = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt
new file mode 100644
index 00000000..cb0f38f2
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt
@@ -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)
+
+ @Query("SELECT * FROM queue_entries ORDER BY position, id")
+ suspend fun entries(): List
+
+ @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?
+}
diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt
index e36805fb..b2622ddc 100644
--- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt
+++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt
@@ -1,11 +1,15 @@
package com.subtracks.data.db
+import androidx.room3.ColumnTypeConverters
import androidx.room3.Database
import androidx.room3.RoomDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
+import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist
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.Song
import com.subtracks.data.model.Source
@@ -21,14 +25,19 @@ import com.subtracks.data.model.SubsonicSource
PlaylistSong::class,
Song::class,
SearchIndex::class,
+ QueueEntry::class,
+ PlaybackCursor::class,
],
- version = 2,
+ version = 3,
exportSchema = true,
)
+@ColumnTypeConverters(QueueKindConverter::class)
abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
abstract fun sourcesDao(): SourcesDao
abstract fun searchDao(): SearchDao
+
+ abstract fun queueDao(): QueueDao
}
diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt
index 5774dac7..349d7681 100644
--- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt
+++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt
@@ -1,6 +1,7 @@
package com.subtracks.data.model
import androidx.room3.ColumnInfo
+import androidx.room3.ColumnTypeConverter
import androidx.room3.Embedded
import androidx.room3.Entity
import androidx.room3.ForeignKey
@@ -173,3 +174,45 @@ data class SearchIndex(
val itemId: 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)
+}
diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt
new file mode 100644
index 00000000..ce7039ef
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt
@@ -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,
+) {
+ val size: Long = entries.sumOf { it.length }
+
+ fun locate(position: Long): Pair? {
+ 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) {
+ 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 {
+ 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
+ }
+}
diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt
index 2cdfb652..24679e4f 100644
--- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt
+++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt
@@ -10,6 +10,7 @@ import com.subtracks.data.sync.SyncService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -34,10 +35,16 @@ class SourceRepository(
}
}
+ fun close() {
+ scope.cancel()
+ }
+
fun sources(): Flow> = db.sourcesDao().sources()
fun activeSourceId(): Flow = db.sourcesDao().activeSourceId()
+ suspend fun activeSourceIdOnce(): Long? = db.sourcesDao().activeSourceIdOnce()
+
fun activeConfig(): Flow = db.sourcesDao().activeSubsonicConfig()
fun coverArt(
@@ -49,6 +56,8 @@ class SourceRepository(
return CoverArtRef(url = url, cacheKey = "${source.id}:$coverArt:$thumbnail")
}
+ fun streamUri(songId: String): String? = active?.streamUri(songId)?.toString()
+
suspend fun addSource(
name: String,
address: String,
diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt
index c96be011..ab9cb5a0 100644
--- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt
+++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt
@@ -71,7 +71,8 @@ class SubsonicSource(
"stream",
buildMap {
put("id", songId)
- put("estimateContentLength", "true")
+ val transcodes = maxBitrate > 0 || !streamFormat.isNullOrEmpty()
+ if (transcodes) put("estimateContentLength", "true")
if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString())
streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) }
},
diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt
index 43a8bee3..61d1c4be 100644
--- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt
+++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt
@@ -4,8 +4,12 @@ import android.content.Context
import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.prefs.createUserPreferences
import com.subtracks.data.repo.LibraryRepository
+import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
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.library.AlbumDetailViewModel
import com.subtracks.ui.library.LibraryViewModel
@@ -26,10 +30,13 @@ fun appModule(
single { SourceRepository(get(), get()) }
single { LibraryRepository(get(), get()) }
single { SyncManager(get()) }
+ single { QueueRepository(get()) }
+ single { MediaSessionConnection(context.applicationContext, get()) }
+ single { PlaybackController(get(), get(), get()) }
viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get()) }
viewModel { SettingsViewModel(get()) }
viewModel { AddSourceViewModel(get(), get()) }
- viewModel { params -> AlbumDetailViewModel(get(), get(), params.get()) }
- viewModel { params -> PlaylistDetailViewModel(get(), get(), params.get()) }
+ viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
+ viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
}
diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt
new file mode 100644
index 00000000..115a9010
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt
@@ -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,
+ 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"
+ }
+}
diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt
new file mode 100644
index 00000000..184fdd00
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt
@@ -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 = _state
+
+ private var player: PlayerHandle? = null
+ private var connecting = false
+ private var pendingPlay: Pair, 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,
+ startPosition: Long,
+ ) {
+ if (player == null) {
+ pendingPlay = entries to startPosition
+ connect()
+ return
+ }
+ scope.launch { start(entries, startPosition) }
+ }
+
+ private suspend fun start(
+ entries: List,
+ 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
+ }
+}
diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt
new file mode 100644
index 00000000..b8e96af1
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt
new file mode 100644
index 00000000..9499386d
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt
@@ -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() },
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt
new file mode 100644
index 00000000..70a0fe82
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt
@@ -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,
+ 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) {
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt b/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt
new file mode 100644
index 00000000..a17c2ba7
--- /dev/null
+++ b/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt
@@ -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 },
+ )
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c5846800..e805a541 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,7 @@ navigationCompose = "2.10.1"
datastore = "1.2.1"
coil = "3.6.3"
koin = "4.2.2"
+media3 = "1.11.1"
okhttp = "5.5.0"
ktlint = "1.8.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-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" }
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" }
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" }