add media3, the playback session and the sql-backed queue

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 143f2accb7
commit 592c27c99c
22 changed files with 2484 additions and 11 deletions
+3
View File
@@ -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')"
]
}
}
+13
View File
@@ -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 },
)
+4
View File
@@ -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" }