diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json new file mode 100644 index 0000000..e9ad490 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json @@ -0,0 +1,354 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "7d53e052483019da2b9d7056072cea79", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "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, '7d53e052483019da2b9d7056072cea79')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 0394809..be66699 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -2,6 +2,8 @@ package it.vfsfitvnm.vimusic import android.content.Context import android.database.Cursor +import android.os.Parcel +import androidx.media3.common.MediaItem import androidx.room.* import androidx.room.migration.AutoMigrationSpec import it.vfsfitvnm.vimusic.models.* @@ -115,6 +117,15 @@ interface Database { @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId JOIN Song ON SongWithAuthors.songId = Song.id WHERE browseId = :artistId ORDER BY Song.ROWID DESC") fun artistSongs(artistId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insertQueue(queuedMediaItems: List) + + @Query("SELECT * FROM QueuedMediaItem") + fun queue(): List + + @Query("DELETE FROM QueuedMediaItem") + fun clearQueue() } @androidx.room.Database( @@ -125,19 +136,22 @@ interface Database { Info::class, SongWithAuthors::class, SearchQuery::class, + QueuedMediaItem::class, ], views = [ SortedSongInPlaylist::class ], - version = 5, + version = 6, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4, spec = DatabaseInitializer.From3To4Migration::class), AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6), ], ) +@TypeConverters(Converters::class) abstract class DatabaseInitializer protected constructor() : RoomDatabase() { abstract val database: Database @@ -145,7 +159,7 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { lateinit var Instance: DatabaseInitializer context(Context) - operator fun invoke() { + operator fun invoke() { if (!::Instance.isInitialized) { Instance = Room .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") @@ -158,6 +172,38 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { class From3To4Migration : AutoMigrationSpec } +@TypeConverters +object Converters { + @TypeConverter + fun mediaItemFromByteArray(value: ByteArray?): MediaItem? { + return value?.let { byteArray -> + runCatching { + val parcel = Parcel.obtain() + parcel.unmarshall(byteArray, 0, byteArray.size) + parcel.setDataPosition(0) + + val bundle = parcel.readBundle(MediaItem::class.java.classLoader) + parcel.recycle() + + bundle?.let(MediaItem.CREATOR::fromBundle) + }.getOrNull() + } + } + + @TypeConverter + fun mediaItemToByteArray(mediaItem: MediaItem?): ByteArray? { + return mediaItem?.toBundle()?.let { persistableBundle -> + val parcel = Parcel.obtain() + parcel.writeBundle(persistableBundle) + + val bytes = parcel.marshall() + parcel.recycle() + + bytes + } + } +} + val Database.internal: RoomDatabase get() = DatabaseInitializer.Instance diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/QueuedMediaItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/QueuedMediaItem.kt new file mode 100644 index 0000000..d76d259 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/QueuedMediaItem.kt @@ -0,0 +1,14 @@ +package it.vfsfitvnm.vimusic.models + + +import androidx.media3.common.MediaItem +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +class QueuedMediaItem( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) val mediaItem: MediaItem, + var position: Long? +) \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt index 97b2718..6b6584e 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt @@ -41,6 +41,7 @@ import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.MainActivity import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.internal +import it.vfsfitvnm.vimusic.models.QueuedMediaItem import it.vfsfitvnm.vimusic.utils.* import it.vfsfitvnm.youtubemusic.Outcome import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint @@ -139,7 +140,35 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback player.addListener(this) player.addAnalyticsListener(PlaybackStatsListener(false, this)) - mediaSession = MediaSessionCompat(this, "PlayerService") + if (preferences.persistentQueue) { + coroutineScope.launch(Dispatchers.IO) { + val queuedSong = Database.queue() + Database.clearQueue() + + if (queuedSong.isEmpty()) return@launch + + val index = queuedSong.indexOfFirst { it.position != null }.coerceAtLeast(0) + + withContext(Dispatchers.Main) { + player.setMediaItems( + queuedSong + .map(QueuedMediaItem::mediaItem) + .map { mediaItem -> + mediaItem.buildUpon() + .setUri(mediaItem.mediaId) + .setCustomCacheKey(mediaItem.mediaId) + .build() + }, + true + ) + player.seekTo(index, queuedSong[index].position ?: 0) + player.playWhenReady = false + player.prepare() + } + } + } + + mediaSession = MediaSessionCompat(baseContext, "PlayerService") mediaSession.setCallback(SessionCallback(player)) mediaSession.setPlaybackState(stateBuilder.build()) mediaSession.isActive = true @@ -161,6 +190,24 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback } override fun onDestroy() { + if (preferences.persistentQueue) { + val mediaItems = player.currentTimeline.mediaItems + val mediaItemIndex = player.currentMediaItemIndex + val mediaItemPosition = player.currentPosition + + Database.internal.queryExecutor.execute { + Database.clearQueue() + Database.insertQueue( + mediaItems.mapIndexed { index, mediaItem -> + QueuedMediaItem( + mediaItem = mediaItem, + position = if (index == mediaItemIndex) mediaItemPosition else null + ) + } + ) + } + } + player.removeListener(this) player.stop() player.release() diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt index 4be5f9e..0d11f6d 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt @@ -239,6 +239,15 @@ fun PlayerSettingsScreen() { } ) + SwitchSettingEntry( + title = "Persistent queue", + text = "Save and restore playing songs", + isChecked = preferences.persistentQueue, + onCheckedChange = { + preferences.persistentQueue = it + } + ) + SettingsEntry( title = "Equalizer", text = "Interact with the system equalizer", diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt index aa0e3f4..b4a709a 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt @@ -26,6 +26,7 @@ class Preferences(holder: SharedPreferences) : SharedPreferences by holder { var exoPlayerDiskCacheMaxSizeBytes by preference("exoPlayerDiskCacheMaxSizeBytes", 512L * 1024 * 1024) var skipSilence by preference("skipSilence", false) var volumeNormalization by preference("volumeNormalization", true) + var persistentQueue by preference("persistentQueue", false) } val Context.preferences: Preferences