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" }