Add player state persistence

This commit is contained in:
vfsfitvnm 2022-06-26 17:07:03 +02:00
parent c336a274d8
commit 8f5bc5e90e
6 changed files with 474 additions and 3 deletions

View file

@ -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')"
]
}
}

View file

@ -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<List<SongWithInfo>>
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insertQueue(queuedMediaItems: List<QueuedMediaItem>)
@Query("SELECT * FROM QueuedMediaItem")
fun queue(): List<QueuedMediaItem>
@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
@ -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

View file

@ -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?
)

View file

@ -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()

View file

@ -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",

View file

@ -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