Add player state persistence concept

This commit is contained in:
vfsfitvnm 2022-06-12 12:19:23 +02:00
parent a0e42473e6
commit e8e69549c6
9 changed files with 564 additions and 10 deletions

View file

@ -0,0 +1,336 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "a595020ea35da1c5de6c6ee75ec234fe",
"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, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, 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": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "totalPlayTimeMs",
"columnName": "totalPlayTimeMs",
"affinity": "INTEGER",
"notNull": true
}
],
"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, 'a595020ea35da1c5de6c6ee75ec234fe')"
]
}
}

View file

@ -56,6 +56,7 @@
<service
android:name=".services.PlayerService"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />

View file

@ -2,10 +2,12 @@ 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.sqlite.db.SupportSQLiteQuery
import it.vfsfitvnm.vimusic.models.*
import kotlinx.coroutines.flow.Flow
import java.io.ByteArrayOutputStream
@Dao
@ -83,6 +85,15 @@ interface Database {
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(song: Song): Long
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insertQueuedMediaItems(queuedMediaItems: List<QueuedMediaItem>)
@Query("SELECT * FROM QueuedMediaItem")
fun queuedMediaItems(): List<QueuedMediaItem>
@Query("DELETE FROM QueuedMediaItem")
fun clearQueuedMediaItems()
@Update
fun update(song: Song)
@ -114,14 +125,18 @@ interface Database {
@androidx.room.Database(
entities = [
Song::class, SongInPlaylist::class, Playlist::class, Info::class, SongWithAuthors::class, SearchQuery::class
Song::class, SongInPlaylist::class, Playlist::class, Info::class, SongWithAuthors::class, SearchQuery::class, QueuedMediaItem::class
],
views = [
SortedSongInPlaylist::class
],
version = 1,
exportSchema = true
version = 2,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
@TypeConverters(Converters::class)
abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
abstract val database: Database
@ -139,6 +154,37 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
}
}
@TypeConverters
object Converters {
// TODO: temporary
@TypeConverter
fun mediaItemFromByteArray(value: ByteArray?): MediaItem? {
return value?.let { byteArray ->
val parcel = Parcel.obtain()
parcel.unmarshall(byteArray, 0, byteArray.size)
parcel.setDataPosition(0);
val pb = parcel.readBundle(MediaItem::class.java.classLoader)
parcel.recycle()
pb?.let {
MediaItem.CREATOR.fromBundle(pb)
}
}
}
// TODO: temporary
@TypeConverter
fun mediaItemToByteArray(mediaItem: MediaItem?): ByteArray? {
return mediaItem?.toBundle()?.let { persistableBundle ->
val parcel = Parcel.obtain()
parcel.writeBundle(persistableBundle)
parcel.marshall().also {
parcel.recycle()
}
}
}
}
val Database.internal: RoomDatabase
get() = DatabaseInitializer.Instance

View file

@ -0,0 +1,13 @@
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

@ -40,10 +40,9 @@ import com.google.common.util.concurrent.ListenableFuture
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.MainActivity
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.utils.RingBuffer
import it.vfsfitvnm.vimusic.utils.YoutubePlayer
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
import it.vfsfitvnm.vimusic.utils.insert
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.models.QueuedMediaItem
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import kotlinx.coroutines.*
import kotlin.math.roundToInt
@ -112,9 +111,55 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
.build()
player.addListener(this)
if (preferences.persistentQueue) {
coroutineScope.launch(Dispatchers.IO) {
val queuedMediaItems = Database.queuedMediaItems()
Database.clearQueuedMediaItems()
if (queuedMediaItems.isEmpty()) return@launch
val index = queuedMediaItems.indexOfFirst { it.position != null }.coerceAtLeast(0)
withContext(Dispatchers.Main) {
player.setMediaItems(
queuedMediaItems
.map(QueuedMediaItem::mediaItem)
.map { mediaItem ->
mediaItem.buildUpon()
.setUri(mediaItem.mediaId)
.setCustomCacheKey(mediaItem.mediaId)
.build()
},
true
)
player.seekTo(index, queuedMediaItems[index].position ?: 0)
player.playWhenReady = false
player.prepare()
}
}
}
}
override fun onDestroy() {
if (preferences.persistentQueue) {
val mediaItems = mediaSession.player.currentTimeline.mediaItems
val mediaItemIndex = mediaSession.player.currentMediaItemIndex
val mediaItemPosition = mediaSession.player.currentPosition
Database.internal.queryExecutor.execute {
Database.clearQueuedMediaItems()
Database.insertQueuedMediaItems(
mediaItems.mapIndexed { index, mediaItem ->
QueuedMediaItem(
mediaItem = mediaItem,
position = if (index == mediaItemIndex) mediaItemPosition else null
)
}
)
}
}
mediaSession.player.release()
mediaSession.release()
cache.release()
@ -135,7 +180,7 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
.add(StopRadioCommand)
.build()
val playerCommands = Player.Commands.Builder().addAllCommands().build()
return MediaSession.ConnectionResult.accept(sessionCommands,playerCommands)
return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands)
}
override fun onCustomCommand(
@ -156,7 +201,7 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
coroutineScope.launch(Dispatchers.Main) {
when (customCommand) {
StartRadioCommand -> mediaSession.player.addMediaItems(it.process().drop(1))
StartArtistRadioCommand -> mediaSession.player.forcePlayFromBeginning(it.process())
StartArtistRadioCommand -> mediaSession.player.forcePlayFromBeginning(it.process())
}
radio = it
}

View file

@ -31,6 +31,7 @@ fun SettingsScreen() {
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
val appearanceRoute = rememberAppearanceRoute()
val playerSettingsRoute = rememberPlayerSettingsRoute()
val notificationRoute = rememberNotificationRoute()
val backupAndRestoreRoute = rememberBackupAndRestoreRoute()
val otherRoute = rememberOtherRoute()
@ -67,6 +68,10 @@ fun SettingsScreen() {
AppearanceScreen()
}
playerSettingsRoute {
PlayerSettingsScreen()
}
notificationRoute {
NotificationScreen()
}
@ -180,6 +185,14 @@ fun SettingsScreen() {
route = appearanceRoute,
)
Entry(
color = colorPalette.magenta,
icon = R.drawable.play,
title = "Player",
description = "Tune the player behavior",
route = playerSettingsRoute,
)
Entry(
color = colorPalette.cyan,
icon = R.drawable.notifications,

View file

@ -0,0 +1,92 @@
package it.vfsfitvnm.vimusic.ui.screens.settings
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.screens.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.LocalPreferences
import it.vfsfitvnm.vimusic.utils.semiBold
@ExperimentalAnimationApi
@Composable
fun PlayerSettingsScreen() {
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
val scrollState = rememberScrollState()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
PlaylistOrAlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val preferences = LocalPreferences.current
Column(
modifier = Modifier
.background(colorPalette.background)
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = 72.dp)
) {
TopAppBar(
modifier = Modifier
.height(52.dp)
) {
Image(
painter = painterResource(R.drawable.chevron_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = pop)
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
BasicText(
text = "Player",
style = typography.m.semiBold
)
Spacer(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
}
SwitchSettingEntry(
title = "[SOON] Persistent queue",
text = "Save and restore playing songs",
isChecked = preferences.persistentQueue,
onCheckedChange = {
preferences.persistentQueue = it
},
isEnabled = false
)
}
}
}
}

View file

@ -11,6 +11,13 @@ fun rememberAppearanceRoute(): Route0 {
}
}
@Composable
fun rememberPlayerSettingsRoute(): Route0 {
return remember {
Route0("PlayerSettingsRoute")
}
}
@Composable
fun rememberNotificationRoute(): Route0 {
return remember {

View file

@ -24,6 +24,7 @@ class Preferences(holder: SharedPreferences) : SharedPreferences by holder {
var thumbnailRoundness by preference("thumbnailRoundness", ThumbnailRoundness.Light)
var coilDiskCacheMaxSizeBytes by preference("coilDiskCacheMaxSizeBytes", 512L * 1024 * 1024)
var displayLikeButtonInNotification by preference("displayLikeButtonInNotification", false)
var persistentQueue by preference("persistentQueue", false)
}
val Context.preferences: Preferences