diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json new file mode 100644 index 0000000..e01fdb5 --- /dev/null +++ b/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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index df20870..1bd1515 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,6 +56,7 @@ diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index c03d026..97d6ca2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/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) + + @Query("SELECT * FROM QueuedMediaItem") + fun queuedMediaItems(): List + + @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 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..bfb95df --- /dev/null +++ b/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? +) 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 2b3c3b0..ce262a7 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt +++ b/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 } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt index bb7738a..65fca9d 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt +++ b/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, 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 new file mode 100644 index 0000000..4bce1a2 --- /dev/null +++ b/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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/routes.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/routes.kt index b5a7d85..2bd4475 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/routes.kt +++ b/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 { 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 3b9ba5f..6a22a6e 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ b/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