Forráskód Böngészése

Add player state persistence concept

vfsfitvnm 3 éve
szülő
commit
e8e69549c6

+ 336 - 0
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json

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

+ 1 - 0
app/src/main/AndroidManifest.xml

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

+ 50 - 4
app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt

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

+ 13 - 0
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/QueuedMediaItem.kt

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

+ 51 - 6
app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt

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

+ 13 - 0
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt

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

+ 92 - 0
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt

@@ -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
+                )
+            }
+        }
+    }
+}

+ 7 - 0
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/routes.kt

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

+ 1 - 0
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt

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