diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index cf1cc4d..f8ed365 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ["master"] + branches: ["redesign"] pull_request: branches: ["master"] diff --git a/README.md b/README.md index 512a55e..075b8d2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ - Background playback - Cache audio chunks for offline playback - Search for songs, albums, artists videos and playlists +- Bookmark artists and albums +- Import playlists - Fetch, display and edit songs lyrics or synchronized lyrics - Local playlist management - Reorder songs in playlist or queue @@ -29,7 +31,7 @@ - Sleep timer - Audio normalization - Persistent queue -- Open YouTube/YouTube Music links (`watch`, `playlist`) +- Open YouTube/YouTube Music links (`watch`, `playlist`, `channel`) - ... ## Installation diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5687011..f33b76f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,18 +5,14 @@ plugins { } android { - signingConfigs { - create("release") { - } - } compileSdk = 33 defaultConfig { applicationId = "it.vfsfitvnm.vimusic" minSdk = 21 targetSdk = 32 - versionCode = 15 - versionName = "0.4.3" + versionCode = 16 + versionName = "0.5.0" } splits { @@ -93,7 +89,7 @@ dependencies { kapt(libs.room.compiler) annotationProcessor(libs.room.compiler) - implementation(projects.youtubeMusic) + implementation(projects.innertube) implementation(projects.kugou) coreLibraryDesugaring(libs.desugaring) diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json new file mode 100644 index 0000000..e0c912d --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json @@ -0,0 +1,610 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "c8f776e899b181081f0230bffec99ac5", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` 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": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "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": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "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": "SongPlaylistMap", + "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_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_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, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "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": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap 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, 'c8f776e899b181081f0230bffec99ac5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json new file mode 100644 index 0000000..8196898 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json @@ -0,0 +1,670 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "b9a9bb1674c7c50be2fab48de5afed43", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` 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": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "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": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "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": "SongPlaylistMap", + "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_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_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, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "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": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap 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, 'b9a9bb1674c7c50be2fab48de5afed43')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json new file mode 100644 index 0000000..a683d90 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json @@ -0,0 +1,670 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "251e713953aacd84fd33b471ed4af391", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` 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": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "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": "SongPlaylistMap", + "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_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_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, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "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": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap 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, '251e713953aacd84fd33b471ed4af391')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json new file mode 100644 index 0000000..a1e0a3d --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json @@ -0,0 +1,646 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "5afda34f61cc45ecd6102a7285ec92d2", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` 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": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "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": "SongPlaylistMap", + "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_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_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, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `info` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "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": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap 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, '5afda34f61cc45ecd6102a7285ec92d2')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f4d9cb..9d1b0a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -72,6 +72,18 @@ android:host="youtu.be" android:pathPrefix="/" android:scheme="https" /> + + + diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 6d12504..3cf1a25 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -10,6 +10,7 @@ import androidx.media3.common.MediaItem import androidx.room.AutoMigration import androidx.room.Dao import androidx.room.Delete +import androidx.room.DeleteColumn import androidx.room.DeleteTable import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -28,6 +29,8 @@ import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteDatabase +import it.vfsfitvnm.vimusic.enums.AlbumSortBy +import it.vfsfitvnm.vimusic.enums.ArtistSortBy import it.vfsfitvnm.vimusic.enums.PlaylistSortBy import it.vfsfitvnm.vimusic.enums.SongSortBy import it.vfsfitvnm.vimusic.enums.SortOrder @@ -35,6 +38,7 @@ import it.vfsfitvnm.vimusic.models.Album import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.models.DetailedSong import it.vfsfitvnm.vimusic.models.DetailedSongWithContentLength +import it.vfsfitvnm.vimusic.models.Event import it.vfsfitvnm.vimusic.models.Format import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.PlaylistPreview @@ -129,6 +133,9 @@ interface Database { @Query("UPDATE Song SET likedAt = :likedAt WHERE id = :songId") fun like(songId: String, likedAt: Long?): Int + @Query("UPDATE Song SET durationText = :durationText WHERE id = :songId") + fun updateDurationText(songId: String, durationText: String): Int + @Query("SELECT lyrics FROM Song WHERE id = :songId") fun lyrics(songId: String): Flow @@ -144,9 +151,80 @@ interface Database { @Query("SELECT * FROM Artist WHERE id = :id") fun artist(id: String): Flow + @Query("SELECT timestamp FROM Artist WHERE id = :id") + fun artistTimestamp(id: String): Long? + + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name DESC") + fun artistsByNameDesc(): Flow> + + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name ASC") + fun artistsByNameAsc(): Flow> + + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID DESC") + fun artistsByRowIdDesc(): Flow> + + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID ASC") + fun artistsByRowIdAsc(): Flow> + + fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow> { + return when (sortBy) { + ArtistSortBy.Name -> when (sortOrder) { + SortOrder.Ascending -> artistsByNameAsc() + SortOrder.Descending -> artistsByNameDesc() + } + ArtistSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> artistsByRowIdAsc() + SortOrder.Descending -> artistsByRowIdDesc() + } + } + } + @Query("SELECT * FROM Album WHERE id = :id") fun album(id: String): Flow + @Query("SELECT timestamp FROM Album WHERE id = :id") + fun albumTimestamp(id: String): Long? + + @Transaction + @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position") + @RewriteQueriesToDropUnusedColumns + fun albumSongs(albumId: String): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title ASC") + fun albumsByTitleAsc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year ASC") + fun albumsByYearAsc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID ASC") + fun albumsByRowIdAsc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title DESC") + fun albumsByTitleDesc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year DESC") + fun albumsByYearDesc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID DESC") + fun albumsByRowIdDesc(): Flow> + + fun albums(sortBy: AlbumSortBy, sortOrder: SortOrder): Flow> { + return when (sortBy) { + AlbumSortBy.Title -> when (sortOrder) { + SortOrder.Ascending -> albumsByTitleAsc() + SortOrder.Descending -> albumsByTitleDesc() + } + AlbumSortBy.Year -> when (sortOrder) { + SortOrder.Ascending -> albumsByYearAsc() + SortOrder.Descending -> albumsByYearDesc() + } + AlbumSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> albumsByRowIdAsc() + SortOrder.Descending -> albumsByRowIdDesc() + } + } + } + @Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id") fun incrementTotalPlayTimeMs(id: String, addition: Long) @@ -190,11 +268,6 @@ interface Database { @RewriteQueriesToDropUnusedColumns fun artistSongs(artistId: String): Flow> - @Transaction - @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position") - @RewriteQueriesToDropUnusedColumns - fun albumSongs(albumId: String): Flow> - @Query("SELECT * FROM Format WHERE songId = :songId") fun format(songId: String): Flow @@ -220,27 +293,29 @@ interface Database { @Query("SELECT loudnessDb FROM Format WHERE songId = :songId") fun loudnessDb(songId: String): Flow + @Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query") + fun search(query: String): Flow> + + @Transaction + @Query("SELECT Song.* FROM Event JOIN Song ON Song.id = songId GROUP BY songId ORDER BY SUM(CAST(playTime AS REAL) / (((:now - timestamp) / 86400000) + 1)) DESC LIMIT 1") + @RewriteQueriesToDropUnusedColumns + fun trending(now: Long = System.currentTimeMillis()): Flow + + @Insert + fun insert(event: Event) + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(format: Format) @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(searchQuery: SearchQuery) - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(info: Artist): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(info: Album): Long - @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(playlist: Playlist): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(songPlaylistMap: SongPlaylistMap): Long - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(songAlbumMap: SongAlbumMap): Long - @Insert(onConflict = OnConflictStrategy.ABORT) fun insert(songArtistMap: SongArtistMap): Long @@ -250,75 +325,56 @@ interface Database { @Insert(onConflict = OnConflictStrategy.ABORT) fun insert(queuedMediaItems: List) + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertSongPlaylistMaps(songPlaylistMaps: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(album: Album, songAlbumMap: SongAlbumMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(artists: List, songArtistMaps: List) + @Transaction fun insert(mediaItem: MediaItem, block: (Song) -> Song = { it }) { val song = Song( id = mediaItem.mediaId, title = mediaItem.mediaMetadata.title!!.toString(), artistsText = mediaItem.mediaMetadata.artist?.toString(), - durationText = mediaItem.mediaMetadata.extras?.getString("durationText")!!, + durationText = mediaItem.mediaMetadata.extras?.getString("durationText"), thumbnailUrl = mediaItem.mediaMetadata.artworkUri?.toString() ).let(block).also { song -> if (insert(song) == -1L) return } mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> - Album( - id = albumId, - title = mediaItem.mediaMetadata.albumTitle?.toString(), - year = null, - authorsText = null, - thumbnailUrl = null, - shareUrl = null, - timestamp = null, - ).also(::insert) - - upsert( - SongAlbumMap( - songId = song.id, - albumId = albumId, - position = null - ) + insert( + Album(id = albumId, title = mediaItem.mediaMetadata.albumTitle?.toString()), + SongAlbumMap(songId = song.id, albumId = albumId, position = null) ) } mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames -> mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")?.let { artistIds -> - artistNames.mapIndexed { index, artistName -> - Artist( - id = artistIds[index], - name = artistName, - thumbnailUrl = null, - info = null, - timestamp = null, - ).also(::insert) + if (artistNames.size == artistIds.size) { + insert( + artistNames.mapIndexed { index, artistName -> + Artist(id = artistIds[index], name = artistName) + }, + artistIds.map { artistId -> + SongArtistMap(songId = song.id, artistId = artistId) + } + ) } } - }?.forEach { artist -> - insert( - SongArtistMap( - songId = song.id, - artistId = artist.id - ) - ) } } - @Update - fun update(song: Song) - @Update fun update(artist: Artist) @Update fun update(album: Album) - @Update - fun update(songAlbumMap: SongAlbumMap) - - @Update - fun update(songPlaylistMap: SongPlaylistMap) - @Update fun update(playlist: Playlist) @@ -337,9 +393,6 @@ interface Database { @Delete fun delete(playlist: Playlist) - @Delete - fun delete(playlist: Album) - @Delete fun delete(songPlaylistMap: SongPlaylistMap) } @@ -356,11 +409,12 @@ interface Database { SearchQuery::class, QueuedMediaItem::class, Format::class, + Event::class, ], views = [ SortedSongPlaylistMap::class ], - version = 17, + version = 21, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -376,6 +430,10 @@ interface Database { AutoMigration(from = 13, to = 14), AutoMigration(from = 15, to = 16), AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19), + AutoMigration(from = 19, to = 20), + AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class), ], ) @TypeConverters(Converters::class) @@ -386,7 +444,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") @@ -509,6 +567,14 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { it.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } + + @DeleteColumn.Entries( + DeleteColumn("Artist", "shuffleVideoId"), + DeleteColumn("Artist", "shufflePlaylistId"), + DeleteColumn("Artist", "radioVideoId"), + DeleteColumn("Artist", "radioPlaylistId"), + ) + class From20To21Migration : AutoMigrationSpec } @TypeConverters diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt index df38c92..87f214a 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt @@ -6,10 +6,10 @@ import android.content.Intent import android.content.ServiceConnection import android.content.SharedPreferences import android.graphics.Bitmap -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.IBinder +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.ExperimentalAnimationApi @@ -19,14 +19,14 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.systemBars import androidx.compose.material.ripple.LocalRippleTheme import androidx.compose.material.ripple.RippleAlpha @@ -35,20 +35,24 @@ import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.coerceIn import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.valentinilk.shimmer.LocalShimmerTheme @@ -59,36 +63,39 @@ import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.service.PlayerService import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.collapsedAnchor -import it.vfsfitvnm.vimusic.ui.components.dismissedAnchor -import it.vfsfitvnm.vimusic.ui.components.expandedAnchor import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState -import it.vfsfitvnm.vimusic.ui.screens.HomeScreen -import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen +import it.vfsfitvnm.vimusic.ui.screens.albumRoute +import it.vfsfitvnm.vimusic.ui.screens.artistRoute +import it.vfsfitvnm.vimusic.ui.screens.home.HomeScreen +import it.vfsfitvnm.vimusic.ui.screens.player.Player +import it.vfsfitvnm.vimusic.ui.screens.playlistRoute import it.vfsfitvnm.vimusic.ui.styling.Appearance import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.colorPaletteOf import it.vfsfitvnm.vimusic.ui.styling.dynamicColorPaletteOf import it.vfsfitvnm.vimusic.ui.styling.typographyOf -import it.vfsfitvnm.vimusic.ui.views.PlayerView +import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey +import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.getEnum import it.vfsfitvnm.vimusic.utils.intent import it.vfsfitvnm.vimusic.utils.listener import it.vfsfitvnm.vimusic.utils.preferences import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.requests.playlistPage +import it.vfsfitvnm.youtubemusic.requests.song import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { - companion object { - private var alreadyRunning = false - } - private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service is PlayerService.Binder) { @@ -102,7 +109,6 @@ class MainActivity : ComponentActivity() { } private var binder by mutableStateOf(null) - private var uri by mutableStateOf(null, neverEqualPolicy()) override fun onStart() { super.onStart() @@ -120,19 +126,16 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) - val playerBottomSheetAnchor = when { - intent?.extras?.getBoolean("expandPlayerBottomSheet") == true -> expandedAnchor - alreadyRunning -> collapsedAnchor - else -> dismissedAnchor.also { alreadyRunning = true } - } - - uri = intent?.data + val launchedFromNotification = intent?.extras?.getBoolean("expandPlayerBottomSheet") == true setContent { val coroutineScope = rememberCoroutineScope() val isSystemInDarkTheme = isSystemInDarkTheme() - var appearance by remember(isSystemInDarkTheme) { + var appearance by rememberSaveable( + isSystemInDarkTheme, + stateSaver = Appearance.Companion + ) { with(preferences) { val colorPaletteName = getEnum(colorPaletteNameKey, ColorPaletteName.Dynamic) val colorPaletteMode = getEnum(colorPaletteModeKey, ColorPaletteMode.System) @@ -230,6 +233,7 @@ class MainActivity : ComponentActivity() { ) } } + thumbnailRoundnessKey -> { val thumbnailRoundness = sharedPreferences.getEnum(key, ThumbnailRoundness.Light) @@ -297,58 +301,45 @@ class MainActivity : ComponentActivity() { .fillMaxSize() .background(appearance.colorPalette.background0) ) { - val paddingValues = WindowInsets.systemBars.asPaddingValues() + val density = LocalDensity.current + val windowsInsets = WindowInsets.systemBars + val bottomDp = with(density) { windowsInsets.getBottom(density).toDp() } val playerBottomSheetState = rememberBottomSheetState( dismissedBound = 0.dp, - collapsedBound = Dimensions.collapsedPlayer + paddingValues.calculateBottomPadding(), + collapsedBound = Dimensions.collapsedPlayer + bottomDp, expandedBound = maxHeight, - initialAnchor = playerBottomSheetAnchor ) - val playerAwarePaddingValues = if (playerBottomSheetState.isDismissed) { - paddingValues - } else { - object : PaddingValues by paddingValues { - override fun calculateBottomPadding(): Dp = - paddingValues.calculateBottomPadding() + Dimensions.collapsedPlayer + val playerAwareWindowInsets by remember(bottomDp, playerBottomSheetState.value) { + derivedStateOf { + val bottom = playerBottomSheetState.value.coerceIn(bottomDp, playerBottomSheetState.collapsedBound) + + windowsInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .add(WindowInsets(bottom = bottom)) } } CompositionLocalProvider( LocalAppearance provides appearance, - LocalOverscrollConfiguration provides null, - LocalIndication provides rememberRipple(bounded = false), + LocalIndication provides rememberRipple(bounded = true), LocalRippleTheme provides rippleTheme, LocalShimmerTheme provides shimmerTheme, LocalPlayerServiceBinder provides binder, - LocalPlayerAwarePaddingValues provides playerAwarePaddingValues + LocalPlayerAwareWindowInsets provides playerAwareWindowInsets ) { - when (val uri = uri) { - null -> { - HomeScreen() - - PlayerView( - layoutState = playerBottomSheetState, - modifier = Modifier - .align(Alignment.BottomCenter) - ) - - DisposableEffect(binder?.player) { - binder?.player?.listener(object : Player.Listener { - override fun onMediaItemTransition( - mediaItem: MediaItem?, - reason: Int - ) { - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null) { - playerBottomSheetState.expand(tween(500)) - } - } - }) ?: onDispose { } - } + HomeScreen( + onPlaylistUrl = { url -> + onNewIntent(Intent.parseUri(url, 0)) } - else -> IntentUriScreen(uri = uri) - } + ) + + Player( + layoutState = playerBottomSheetState, + modifier = Modifier + .align(Alignment.BottomCenter) + ) BottomSheetMenu( state = LocalMenuState.current, @@ -356,13 +347,83 @@ class MainActivity : ComponentActivity() { .align(Alignment.BottomCenter) ) } + + DisposableEffect(binder?.player) { + val player = binder?.player ?: return@DisposableEffect onDispose { } + + if (player.currentMediaItem == null) { + if (!playerBottomSheetState.isDismissed) { + playerBottomSheetState.dismiss() + } + } else { + if (playerBottomSheetState.isDismissed) { + if (launchedFromNotification) { + intent.replaceExtras(Bundle()) + playerBottomSheetState.expandSoft() + } else { + playerBottomSheetState.collapseSoft() + } + } + } + + player.listener(object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null) { + playerBottomSheetState.expand(tween(500)) + } + } + }) + } } } + + onNewIntent(intent) } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - uri = intent?.data + + val uri = intent?.data ?: return + + intent.data = null + this.intent = null + + Toast.makeText(this, "Opening url...", Toast.LENGTH_SHORT).show() + + lifecycleScope.launch(Dispatchers.IO) { + when (val path = uri.pathSegments.firstOrNull()) { + "playlist" -> uri.getQueryParameter("list")?.let { playlistId -> + val browseId = "VL$playlistId" + + if (playlistId.startsWith("OLAK5uy_")) { + Innertube.playlistPage(BrowseBody(browseId = browseId))?.getOrNull()?.let { + it.songsPage?.items?.firstOrNull()?.album?.endpoint?.browseId?.let { browseId -> + albumRoute.ensureGlobal(browseId) + } + } + } else { + playlistRoute.ensureGlobal(browseId) + } + } + + "channel", "c" -> uri.lastPathSegment?.let { channelId -> + artistRoute.ensureGlobal(channelId) + } + + else -> when { + path == "watch" -> uri.getQueryParameter("v") + uri.host == "youtu.be" -> path + else -> null + }?.let { videoId -> + Innertube.song(videoId)?.getOrNull()?.let { song -> + val binder = snapshotFlow { binder }.filterNotNull().first() + withContext(Dispatchers.Main) { + binder.player.forcePlay(song.asMediaItem) + } + } + } + } + } } private fun setSystemBarAppearance(isDark: Boolean) { @@ -385,4 +446,4 @@ class MainActivity : ComponentActivity() { val LocalPlayerServiceBinder = staticCompositionLocalOf { null } -val LocalPlayerAwarePaddingValues = staticCompositionLocalOf { TODO() } +val LocalPlayerAwareWindowInsets = staticCompositionLocalOf { TODO() } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/AlbumSortBy.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/AlbumSortBy.kt new file mode 100644 index 0000000..4d99975 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/AlbumSortBy.kt @@ -0,0 +1,7 @@ +package it.vfsfitvnm.vimusic.enums + +enum class AlbumSortBy { + Title, + Year, + DateAdded +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ArtistSortBy.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ArtistSortBy.kt new file mode 100644 index 0000000..2df4053 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ArtistSortBy.kt @@ -0,0 +1,6 @@ +package it.vfsfitvnm.vimusic.enums + +enum class ArtistSortBy { + Name, + DateAdded +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt index 01bcb6e..9fd8ba3 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt @@ -22,11 +22,4 @@ enum class ThumbnailRoundness { Heavy -> RoundedCornerShape(8.dp) } } - - companion object { - val shape: Shape - @Composable - @ReadOnlyComposable - get() = LocalAppearance.current.thumbnailShape - } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt index e07b81f..57f6827 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt @@ -8,10 +8,11 @@ import androidx.room.PrimaryKey @Entity data class Album( @PrimaryKey val id: String, - val title: String?, + val title: String? = null, val thumbnailUrl: String? = null, val year: String? = null, val authorsText: String? = null, val shareUrl: String? = null, - val timestamp: Long? + val timestamp: Long? = null, + val bookmarkedAt: Long? = null ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt index bca49ff..f0d3b37 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt @@ -8,12 +8,9 @@ import androidx.room.PrimaryKey @Entity data class Artist( @PrimaryKey val id: String, - val name: String, - val thumbnailUrl: String?, - val info: String?, - val shuffleVideoId: String? = null, - val shufflePlaylistId: String? = null, - val radioVideoId: String? = null, - val radioPlaylistId: String? = null, - val timestamp: Long? + val name: String? = null, + val thumbnailUrl: String? = null, + val info: String? = null, + val timestamp: Long? = null, + val bookmarkedAt: Long? = null, ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/DetailedSong.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/DetailedSong.kt index 4881e00..13d6051 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/DetailedSong.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/DetailedSong.kt @@ -9,7 +9,7 @@ open class DetailedSong( val id: String, val title: String, val artistsText: String? = null, - val durationText: String, + val durationText: String?, val thumbnailUrl: String?, val totalPlayTimeMs: Long = 0, @Relation( diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt new file mode 100644 index 0000000..912b88a --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt @@ -0,0 +1,25 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Immutable +@Entity( + foreignKeys = [ + ForeignKey( + entity = Song::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class Event( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + val timestamp: Long, + val playTime: Long +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt index 52ae2a6..161a3ca 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt @@ -2,5 +2,5 @@ package it.vfsfitvnm.vimusic.models data class Info( val id: String, - val name: String + val name: String? ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt index cb3f47e..ac26fda 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt @@ -19,9 +19,4 @@ data class PlaylistWithSongs( ) ) val songs: List -) { - companion object { - val Empty = PlaylistWithSongs(Playlist(-1, ""), emptyList()) - val NotFound = PlaylistWithSongs(Playlist(-2, "Not found"), emptyList()) - } -} +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt index 8c44aa7..f29e138 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt @@ -10,7 +10,7 @@ data class Song( @PrimaryKey val id: String, val title: String, val artistsText: String? = null, - val durationText: String, + val durationText: String?, val thumbnailUrl: String?, val lyrics: String? = null, val synchronizedLyrics: String? = null, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumSaver.kt new file mode 100644 index 0000000..2a62bb4 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumSaver.kt @@ -0,0 +1,31 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.models.Album + +object AlbumSaver : Saver> { + override fun SaverScope.save(value: Album): List = listOf( + value.id, + value.title, + value.thumbnailUrl, + value.year, + value.authorsText, + value.shareUrl, + value.timestamp, + value.bookmarkedAt, + ) + + override fun restore(value: List): Album = Album( + id = value[0] as String, + title = value[1] as String, + thumbnailUrl = value[2] as String?, + year = value[3] as String?, + authorsText = value[4] as String?, + shareUrl = value[5] as String?, + timestamp = value[6] as Long?, + bookmarkedAt = value[7] as Long?, + ) +} + +val AlbumListSaver = listSaver(AlbumSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ArtistSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ArtistSaver.kt new file mode 100644 index 0000000..4112741 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ArtistSaver.kt @@ -0,0 +1,27 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.models.Artist + +object ArtistSaver : Saver> { + override fun SaverScope.save(value: Artist): List = listOf( + value.id, + value.name, + value.thumbnailUrl, + value.info, + value.timestamp, + value.bookmarkedAt, + ) + + override fun restore(value: List): Artist = Artist( + id = value[0] as String, + name = value[1] as String, + thumbnailUrl = value[2] as String?, + info = value[3] as String?, + timestamp = value[4] as Long?, + bookmarkedAt = value[5] as Long?, + ) +} + +val ArtistListSaver = listSaver(ArtistSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/DetailedSongSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/DetailedSongSaver.kt new file mode 100644 index 0000000..e6dd4a9 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/DetailedSongSaver.kt @@ -0,0 +1,33 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.models.DetailedSong + +object DetailedSongSaver : Saver> { + override fun SaverScope.save(value: DetailedSong) = + listOf( + value.id, + value.title, + value.artistsText, + value.durationText, + value.thumbnailUrl, + value.totalPlayTimeMs, + value.albumId, + value.artists?.let { with(InfoListSaver) { save(it) } } + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = DetailedSong( + id = value[0] as String, + title = value[1] as String, + artistsText = value[2] as String?, + durationText = value[3] as String?, + thumbnailUrl = value[4] as String?, + totalPlayTimeMs = value[5] as Long, + albumId = value[6] as String?, + artists = (value[7] as List>?)?.let(InfoListSaver::restore) + ) +} + +val DetailedSongListSaver = listSaver(DetailedSongSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InfoSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InfoSaver.kt new file mode 100644 index 0000000..12bd22d --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InfoSaver.kt @@ -0,0 +1,13 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.models.Info + +object InfoSaver : Saver> { + override fun SaverScope.save(value: Info) = listOf(value.id, value.name) + + override fun restore(value: List) = Info(id = value[0] as String, name = value[1]) +} + +val InfoListSaver = listSaver(InfoSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeAlbumItemSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeAlbumItemSaver.kt new file mode 100644 index 0000000..78e1854 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeAlbumItemSaver.kt @@ -0,0 +1,24 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +object InnertubeAlbumItemSaver : Saver> { + override fun SaverScope.save(value: Innertube.AlbumItem): List = listOf( + value.info?.let { with(InnertubeBrowseInfoSaver) { save(it) } }, + value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } }, + value.year, + value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } } + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = Innertube.AlbumItem( + info = (value[0] as List?)?.let(InnertubeBrowseInfoSaver::restore), + authors = (value[1] as List>?)?.let(InnertubeBrowseInfoListSaver::restore), + year = value[2] as String?, + thumbnail = (value[3] as List?)?.let(InnertubeThumbnailSaver::restore) + ) +} + +val InnertubeAlbumItemListSaver = listSaver(InnertubeAlbumItemSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeArtistItemSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeArtistItemSaver.kt new file mode 100644 index 0000000..fd94ebe --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeArtistItemSaver.kt @@ -0,0 +1,21 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +object InnertubeArtistItemSaver : Saver> { + override fun SaverScope.save(value: Innertube.ArtistItem): List = listOf( + value.info?.let { with(InnertubeBrowseInfoSaver) { save(it) } }, + value.subscribersCountText, + value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } } + ) + + override fun restore(value: List) = Innertube.ArtistItem( + info = (value[0] as List?)?.let(InnertubeBrowseInfoSaver::restore), + subscribersCountText = value[1] as String?, + thumbnail = (value[2] as List?)?.let(InnertubeThumbnailSaver::restore) + ) +} + +val InnertubeArtistItemListSaver = listSaver(InnertubeArtistItemSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeArtistPageSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeArtistPageSaver.kt new file mode 100644 index 0000000..5a86315 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeArtistPageSaver.kt @@ -0,0 +1,36 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +object InnertubeArtistPageSaver : Saver> { + override fun SaverScope.save(value: Innertube.ArtistPage) = listOf( + value.name, + value.description, + value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } }, + value.shuffleEndpoint?.let { with(InnertubeWatchEndpointSaver) { save(it) } }, + value.radioEndpoint?.let { with(InnertubeWatchEndpointSaver) { save(it) } }, + value.songs?.let { with(InnertubeSongItemListSaver) { save(it) } }, + value.songsEndpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } }, + value.albums?.let { with(InnertubeAlbumItemListSaver) { save(it) } }, + value.albumsEndpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } }, + value.singles?.let { with(InnertubeAlbumItemListSaver) { save(it) } }, + value.singlesEndpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } }, + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = Innertube.ArtistPage( + name = value[0] as String?, + description = value[1] as String?, + thumbnail = (value[2] as List?)?.let(InnertubeThumbnailSaver::restore), + shuffleEndpoint = (value[3] as List?)?.let(InnertubeWatchEndpointSaver::restore), + radioEndpoint = (value[4] as List?)?.let(InnertubeWatchEndpointSaver::restore), + songs = (value[5] as List>?)?.let(InnertubeSongItemListSaver::restore), + songsEndpoint = (value[6] as List?)?.let(InnertubeBrowseEndpointSaver::restore), + albums = (value[7] as List>?)?.let(InnertubeAlbumItemListSaver::restore), + albumsEndpoint = (value[8] as List?)?.let(InnertubeBrowseEndpointSaver::restore), + singles = (value[9] as List>?)?.let(InnertubeAlbumItemListSaver::restore), + singlesEndpoint = (value[10] as List?)?.let(InnertubeBrowseEndpointSaver::restore), + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeBrowseEndpointSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeBrowseEndpointSaver.kt new file mode 100644 index 0000000..2ccd732 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeBrowseEndpointSaver.kt @@ -0,0 +1,18 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint + +object InnertubeBrowseEndpointSaver : Saver> { + override fun SaverScope.save(value: NavigationEndpoint.Endpoint.Browse) = listOf( + value.browseId, + value.params + ) + + override fun restore(value: List) = NavigationEndpoint.Endpoint.Browse( + browseId = value[0] as String, + params = value[1] as String?, + browseEndpointContextSupportedConfigs = null + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeBrowseInfoSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeBrowseInfoSaver.kt new file mode 100644 index 0000000..9a0e876 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeBrowseInfoSaver.kt @@ -0,0 +1,20 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint + +object InnertubeBrowseInfoSaver : Saver, List> { + override fun SaverScope.save(value: Innertube.Info) = listOf( + value.name, + value.endpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } } + ) + + override fun restore(value: List) = Innertube.Info( + name = value[0] as String?, + endpoint = (value[1] as List?)?.let(InnertubeBrowseEndpointSaver::restore) + ) +} + +val InnertubeBrowseInfoListSaver = listSaver(InnertubeBrowseInfoSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeItemsPageSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeItemsPageSaver.kt new file mode 100644 index 0000000..7ecd85b --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeItemsPageSaver.kt @@ -0,0 +1,4 @@ +package it.vfsfitvnm.vimusic.savers + +val InnertubeSongsPageSaver = innertubeItemsPageSaver(InnertubeSongItemListSaver) +val InnertubeAlbumsPageSaver = innertubeItemsPageSaver(InnertubeAlbumItemListSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistItemSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistItemSaver.kt new file mode 100644 index 0000000..3f82dfc --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistItemSaver.kt @@ -0,0 +1,23 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +object InnertubePlaylistItemSaver : Saver> { + override fun SaverScope.save(value: Innertube.PlaylistItem): List = listOf( + value.info?.let { with(InnertubeBrowseInfoSaver) { save(it) } }, + value.channel?.let { with(InnertubeBrowseInfoSaver) { save(it) } }, + value.songCount, + value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } } + ) + + override fun restore(value: List) = Innertube.PlaylistItem( + info = (value[0] as List?)?.let(InnertubeBrowseInfoSaver::restore), + channel = (value[1] as List?)?.let(InnertubeBrowseInfoSaver::restore), + songCount = value[2] as Int?, + thumbnail = (value[3] as List?)?.let(InnertubeThumbnailSaver::restore) + ) +} + +val InnertubePlaylistItemListSaver = listSaver(InnertubePlaylistItemSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistOrAlbumPageSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistOrAlbumPageSaver.kt new file mode 100644 index 0000000..de0fdd9 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistOrAlbumPageSaver.kt @@ -0,0 +1,28 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +object InnertubePlaylistOrAlbumPageSaver : Saver> { + override fun SaverScope.save(value: Innertube.PlaylistOrAlbumPage): List = listOf( + value.title, + value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } }, + value.year, + value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } } , + value.url, + value.songsPage?.let { with(InnertubeSongsPageSaver) { save(it) } }, + value.otherVersions?.let { with(InnertubeAlbumItemListSaver) { save(it) } }, + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = Innertube.PlaylistOrAlbumPage( + title = value[0] as String?, + authors = (value[1] as List>?)?.let(InnertubeBrowseInfoListSaver::restore), + year = value[2] as String?, + thumbnail = (value[3] as List?)?.let(InnertubeThumbnailSaver::restore), + url = value[4] as String?, + songsPage = (value[5] as List?)?.let(InnertubeSongsPageSaver::restore), + otherVersions = (value[6] as List>?)?.let(InnertubeAlbumItemListSaver::restore), + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeRelatedPageSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeRelatedPageSaver.kt new file mode 100644 index 0000000..46f9f16 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeRelatedPageSaver.kt @@ -0,0 +1,22 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +object InnertubeRelatedPageSaver : Saver> { + override fun SaverScope.save(value: Innertube.RelatedPage): List = listOf( + value.songs?.let { with(InnertubeSongItemListSaver) { save(it) } }, + value.playlists?.let { with(InnertubePlaylistItemListSaver) { save(it) } }, + value.albums?.let { with(InnertubeAlbumItemListSaver) { save(it) } }, + value.artists?.let { with(InnertubeArtistItemListSaver) { save(it) } }, + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = Innertube.RelatedPage( + songs = (value[0] as List>?)?.let(InnertubeSongItemListSaver::restore), + playlists = (value[1] as List>?)?.let(InnertubePlaylistItemListSaver::restore), + albums = (value[2] as List>?)?.let(InnertubeAlbumItemListSaver::restore), + artists = (value[3] as List>?)?.let(InnertubeArtistItemListSaver::restore), + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeSongItemSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeSongItemSaver.kt new file mode 100644 index 0000000..0a696b9 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeSongItemSaver.kt @@ -0,0 +1,26 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +object InnertubeSongItemSaver : Saver> { + override fun SaverScope.save(value: Innertube.SongItem): List = listOf( + value.info?.let { with(InnertubeWatchInfoSaver) { save(it) } }, + value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } }, + value.album?.let { with(InnertubeBrowseInfoSaver) { save(it) } }, + value.durationText, + value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } } + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = Innertube.SongItem( + info = (value[0] as List?)?.let(InnertubeWatchInfoSaver::restore), + authors = (value[1] as List>?)?.let(InnertubeBrowseInfoListSaver::restore), + album = (value[2] as List?)?.let(InnertubeBrowseInfoSaver::restore), + durationText = value[3] as String?, + thumbnail = (value[4] as List?)?.let(InnertubeThumbnailSaver::restore) + ) +} + +val InnertubeSongItemListSaver = listSaver(InnertubeSongItemSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeThumbnailSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeThumbnailSaver.kt new file mode 100644 index 0000000..2aa3b64 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeThumbnailSaver.kt @@ -0,0 +1,19 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.models.Thumbnail + +object InnertubeThumbnailSaver : Saver> { + override fun SaverScope.save(value: Thumbnail) = listOf( + value.url, + value.width, + value.height + ) + + override fun restore(value: List) = Thumbnail( + url = value[0] as String, + width = value[1] as Int, + height = value[2] as Int?, + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeVideoItemSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeVideoItemSaver.kt new file mode 100644 index 0000000..2c8f245 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeVideoItemSaver.kt @@ -0,0 +1,26 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +object InnertubeVideoItemSaver : Saver> { + override fun SaverScope.save(value: Innertube.VideoItem): List = listOf( + value.info?.let { with(InnertubeWatchInfoSaver) { save(it) } }, + value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } }, + value.viewsText, + value.durationText, + value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } } + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = Innertube.VideoItem( + info = (value[0] as List?)?.let(InnertubeWatchInfoSaver::restore), + authors = (value[1] as List>?)?.let(InnertubeBrowseInfoListSaver::restore), + viewsText = value[2] as String?, + durationText = value[3] as String?, + thumbnail = (value[4] as List?)?.let(InnertubeThumbnailSaver::restore) + ) +} + +val InnertubeVideoItemListSaver = listSaver(InnertubeVideoItemSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeWatchEndpointSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeWatchEndpointSaver.kt new file mode 100644 index 0000000..69feee1 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeWatchEndpointSaver.kt @@ -0,0 +1,24 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint + +object InnertubeWatchEndpointSaver : Saver> { + override fun SaverScope.save(value: NavigationEndpoint.Endpoint.Watch) = listOf( + value.params, + value.playlistId, + value.videoId, + value.index, + value.playlistSetVideoId, + ) + + override fun restore(value: List) = NavigationEndpoint.Endpoint.Watch( + params = value[0] as String?, + playlistId = value[1] as String?, + videoId = value[2] as String?, + index = value[3] as Int?, + playlistSetVideoId = value[4] as String?, + watchEndpointMusicSupportedConfigs = null + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeWatchInfoSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeWatchInfoSaver.kt new file mode 100644 index 0000000..1090e0b --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubeWatchInfoSaver.kt @@ -0,0 +1,18 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint + +object InnertubeWatchInfoSaver : Saver, List> { + override fun SaverScope.save(value: Innertube.Info) = listOf( + value.name, + value.endpoint?.let { with(InnertubeWatchEndpointSaver) { save(it) } }, + ) + + override fun restore(value: List) = Innertube.Info( + name = value[0] as String?, + endpoint = (value[1] as List?)?.let(InnertubeWatchEndpointSaver::restore) + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistPreviewSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistPreviewSaver.kt new file mode 100644 index 0000000..56d3fe1 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistPreviewSaver.kt @@ -0,0 +1,19 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.models.PlaylistPreview + +object PlaylistPreviewSaver : Saver> { + override fun SaverScope.save(value: PlaylistPreview) = listOf( + with(PlaylistSaver) { save(value.playlist) }, + value.songCount, + ) + + override fun restore(value: List) = PlaylistPreview( + playlist = PlaylistSaver.restore(value[0] as List), + songCount = value[1] as Int, + ) +} + +val PlaylistPreviewListSaver = listSaver(PlaylistPreviewSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistSaver.kt new file mode 100644 index 0000000..c660f57 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistSaver.kt @@ -0,0 +1,19 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.models.Playlist + +object PlaylistSaver : Saver> { + override fun SaverScope.save(value: Playlist): List = listOf( + value.id, + value.name, + value.browseId, + ) + + override fun restore(value: List): Playlist = Playlist( + id = value[0] as Long, + name = value[1] as String, + browseId = value[2] as String?, + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistWithSongsSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistWithSongsSaver.kt new file mode 100644 index 0000000..fe73abd --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/PlaylistWithSongsSaver.kt @@ -0,0 +1,18 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.models.PlaylistWithSongs + +object PlaylistWithSongsSaver : Saver> { + override fun SaverScope.save(value: PlaylistWithSongs) = listOf( + with(PlaylistSaver) { save(value.playlist) }, + with(DetailedSongListSaver) { save(value.songs) }, + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List): PlaylistWithSongs = PlaylistWithSongs( + playlist = PlaylistSaver.restore(value[0] as List), + songs = DetailedSongListSaver.restore(value[1] as List>) + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/Savers.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/Savers.kt new file mode 100644 index 0000000..adf0fa0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/Savers.kt @@ -0,0 +1,52 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.Innertube + +interface ListSaver : Saver, List> { + override fun SaverScope.save(value: List): List + override fun restore(value: List): List +} + +fun resultSaver(saver: Saver) = + object : Saver?, Pair> { + override fun restore(value: Pair) = + value.first?.let(saver::restore)?.let(Result.Companion::success) + ?: value.second?.let(Result.Companion::failure) + + override fun SaverScope.save(value: Result?) = + with(saver) { value?.getOrNull()?.let { save(it) } } to value?.exceptionOrNull() + } + +fun listSaver(saver: Saver) = + object : ListSaver { + override fun restore(value: List) = + value.mapNotNull(saver::restore) + + override fun SaverScope.save(value: List) = + with(saver) { value.mapNotNull { save(it) } } + } + +fun nullableSaver(saver: Saver) = + object : Saver { + override fun SaverScope.save(value: Original?): Saveable? = + value?.let { with(saver) { save(it) } } + + override fun restore(value: Saveable): Original? = + saver.restore(value) + } + +fun innertubeItemsPageSaver(saver: ListSaver>) = + object : Saver, List> { + override fun SaverScope.save(value: Innertube.ItemsPage) = listOf( + value.items?.let { with(saver) { save(it) } }, + value.continuation + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = Innertube.ItemsPage( + items = (value[0] as List>?)?.let(saver::restore), + continuation = value[1] as String? + ) + } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/SearchQuerySaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/SearchQuerySaver.kt new file mode 100644 index 0000000..57df500 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/SearchQuerySaver.kt @@ -0,0 +1,17 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.models.SearchQuery + +object SearchQuerySaver : Saver> { + override fun SaverScope.save(value: SearchQuery): List = listOf( + value.id, + value.query, + ) + + override fun restore(value: List) = SearchQuery( + id = value[0] as Long, + query = value[1] as String + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt index 5225fbe..3c81098 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt @@ -20,6 +20,7 @@ import android.media.session.PlaybackState import android.net.Uri import android.os.Build import android.os.Handler +import android.text.format.DateUtils import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -65,6 +66,7 @@ import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.MainActivity import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize +import it.vfsfitvnm.vimusic.models.Event import it.vfsfitvnm.vimusic.models.QueuedMediaItem import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.utils.InvincibleService @@ -90,8 +92,10 @@ import it.vfsfitvnm.vimusic.utils.shouldBePlaying import it.vfsfitvnm.vimusic.utils.skipSilenceKey import it.vfsfitvnm.vimusic.utils.timer import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey -import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.Innertube import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint +import it.vfsfitvnm.youtubemusic.models.bodies.PlayerBody +import it.vfsfitvnm.youtubemusic.requests.player import kotlin.math.roundToInt import kotlin.system.exitProcess import kotlinx.coroutines.CoroutineScope @@ -100,6 +104,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking @@ -285,11 +290,26 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene val totalPlayTimeMs = playbackStats.totalPlayTimeMs - if (totalPlayTimeMs > 2000) { + if (totalPlayTimeMs > 5000) { query { Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs) } } + + if (totalPlayTimeMs > 30000) { + query { + // THANKS, EXOPLAYER + if (runBlocking { Database.song(mediaItem.mediaId).first() } != null) { + Database.insert( + Event( + songId = mediaItem.mediaId, + timestamp = System.currentTimeMillis(), + playTime = totalPlayTimeMs + ) + ) + } + } + } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -628,9 +648,9 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second) else -> { val urlResult = runBlocking(Dispatchers.IO) { - YouTube.player(videoId) + Innertube.player(PlayerBody(videoId = videoId)) }?.mapCatching { body -> - when (val status = body.playabilityStatus.status) { + when (val status = body.playabilityStatus?.status) { "OK" -> body.streamingData?.adaptiveFormats?.findLast { format -> format.itag == 251 || format.itag == 140 }?.let { format -> @@ -638,6 +658,13 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene player.findNextMediaItemById(videoId) } + if (mediaItem?.mediaMetadata?.extras?.getString("durationText") == null) { + format.approxDurationMs?.div(1000)?.let(DateUtils::formatElapsedTime)?.removePrefix("0")?.let { durationText -> + mediaItem?.mediaMetadata?.extras?.putString("durationText", durationText) + Database.updateDurationText(videoId, durationText) + } + } + query { mediaItem?.let(Database::insert) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Badge.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Badge.kt deleted file mode 100644 index 0e6d10d..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Badge.kt +++ /dev/null @@ -1,22 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components - -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -fun Modifier.badge(color: Color, isDisplayed: Boolean = true, radius: Dp = 4.dp) = - if (isDisplayed) { - drawWithContent { - drawContent() - drawCircle( - color = color, - center = Offset(x = size.width, y = 0.dp.toPx()), - radius = radius.toPx() - ) - } - } else { - this - } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt index f30c980..c2df8b5 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt @@ -10,14 +10,12 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.DraggableState import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf @@ -40,7 +38,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @Composable @@ -74,36 +71,7 @@ fun BottomSheet( onDragEnd = { val velocity = -velocityTracker.calculateVelocity().y velocityTracker.resetTracking() - - if (velocity > 250) { - state.expand() - } else if (velocity < -250) { - if (state.value < state.collapsedBound && onDismiss != null) { - state.dismiss() - onDismiss.invoke() - } else { - state.collapse() - } - } else { - val l0 = state.dismissedBound - val l1 = (state.collapsedBound - state.dismissedBound) / 2 - val l2 = (state.expandedBound - state.collapsedBound) / 2 - val l3 = state.expandedBound - - when (state.value) { - in l0..l1 -> { - if (onDismiss != null) { - state.dismiss() - onDismiss.invoke() - } else { - state.collapse() - } - } - in l1..l2 -> state.collapse() - in l2..l3 -> state.expand() - else -> Unit - } - } + state.performFling(velocity, onDismiss) } ) } @@ -120,11 +88,7 @@ fun BottomSheet( .graphicsLayer { alpha = 1f - (state.progress * 16).coerceAtMost(1f) } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = true), - onClick = state::expandSoft - ) + .clickable(onClick = state::expandSoft) .fillMaxWidth() .height(state.collapsedBound), content = collapsedContent @@ -179,11 +143,11 @@ class BottomSheetState( } } - fun collapse() { + private fun collapse() { collapse(SpringSpec()) } - fun expand() { + private fun expand() { expand(SpringSpec()) } @@ -208,21 +172,53 @@ class BottomSheetState( } } - fun nestedScrollConnection(initialIsTopReached: Boolean = true): NestedScrollConnection { - return object : NestedScrollConnection { - var isTopReached = initialIsTopReached + fun performFling(velocity: Float, onDismiss: (() -> Unit)?) { + if (velocity > 250) { + expand() + } else if (velocity < -250) { + if (value < collapsedBound && onDismiss != null) { + dismiss() + onDismiss.invoke() + } else { + collapse() + } + } else { + val l0 = dismissedBound + val l1 = (collapsedBound - dismissedBound) / 2 + val l2 = (expandedBound - collapsedBound) / 2 + val l3 = expandedBound + + when (value) { + in l0..l1 -> { + if (onDismiss != null) { + dismiss() + onDismiss.invoke() + } else { + collapse() + } + } + in l1..l2 -> collapse() + in l2..l3 -> expand() + else -> Unit + } + } + } + + val preUpPostDownNestedScrollConnection + get() = object : NestedScrollConnection { + var isTopReached = false override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (isExpanded && available.y < 0) { isTopReached = false } - if (isTopReached) { + return if (isTopReached && available.y < 0 && source == NestedScrollSource.Drag) { dispatchRawDelta(available.y) - return available + available + } else { + Offset.Zero } - - return Offset.Zero } override fun onPostScroll( @@ -234,44 +230,30 @@ class BottomSheetState( isTopReached = consumed.y == 0f && available.y > 0 } - return Offset.Zero + return if (isTopReached && source == NestedScrollSource.Drag) { + dispatchRawDelta(available.y) + available + } else { + Offset.Zero + } } override suspend fun onPreFling(available: Velocity): Velocity { - if (isTopReached) { + return if (isTopReached) { val velocity = -available.y - coroutineScope { - if (velocity > 250) { - expand() - } else if (velocity < -250) { - collapse() - } else { - val l0 = dismissedBound - val l1 = (collapsedBound - dismissedBound) / 2 - val l2 = (expandedBound - collapsedBound) / 2 - val l3 = expandedBound + performFling(velocity, null) - when (value) { - in l0..l1 -> collapse() - in l1..l2 -> collapse() - in l2..l3 -> expand() - else -> Unit - } - } - } - - return available + available + } else { + Velocity.Zero } - - return Velocity.Zero } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { isTopReached = false - return super.onPostFling(consumed, available) + return Velocity.Zero } } - } } const val expandedAnchor = 2 diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt deleted file mode 100644 index c50a436..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt +++ /dev/null @@ -1,107 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.R - -@Composable -fun ChunkyButton( - onClick: () -> Unit, - backgroundColor: Color, - modifier: Modifier = Modifier, - text: String? = null, - secondaryText: String? = null, - textStyle: TextStyle = TextStyle.Default, - secondaryTextStyle: TextStyle = TextStyle.Default, - rippleColor: Color = Color.Unspecified, - @DrawableRes icon: Int? = null, - shape: Shape = RoundedCornerShape(16.dp), - colorFilter: ColorFilter = ColorFilter.tint(rippleColor), - isEnabled: Boolean = true, - onMore: (() -> Unit)? = null -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier - .clip(shape) - .background(backgroundColor) - .clickable( - indication = rememberRipple(bounded = true, color = rippleColor), - interactionSource = remember { MutableInteractionSource() }, - enabled = isEnabled, - onClick = onClick - ) - .padding(horizontal = 24.dp, vertical = 16.dp) - ) { - icon?.let { icon -> - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = colorFilter, - modifier = Modifier - .size(20.dp) - ) - } - - text?.let { text -> - Column { - BasicText( - text = text, - style = textStyle - ) - - secondaryText?.let { secondaryText -> - BasicText( - text = secondaryText, - style = secondaryTextStyle - ) - } - } - } - - onMore?.let { onMore -> - Spacer( - modifier = Modifier - .background(rippleColor.copy(alpha = 0.6f)) - .width(1.dp) - .height(24.dp) - ) - - Image( - // TODO: this is themed... - painter = painterResource(R.drawable.ellipsis_vertical), - contentDescription = null, - colorFilter = ColorFilter.tint(rippleColor.copy(alpha = 0.6f)), - modifier = Modifier - .clickable(onClick = onMore) - .size(20.dp) - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt deleted file mode 100644 index 9eafc96..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt +++ /dev/null @@ -1,50 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components - -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp - -@Composable -fun ChipGroup( - items: List>, - value: T, - selectedBackgroundColor: Color, - unselectedBackgroundColor: Color, - selectedTextStyle: TextStyle, - unselectedTextStyle: TextStyle, - modifier: Modifier = Modifier, - shape: Shape = RoundedCornerShape(16.dp), - onValueChanged: (T) -> Unit -) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .then(modifier) - ) { - items.forEach { chipItem -> - ChunkyButton( - text = chipItem.text, - textStyle = if (chipItem.value == value) selectedTextStyle else unselectedTextStyle, - backgroundColor = if (chipItem.value == value) selectedBackgroundColor else unselectedBackgroundColor, - shape = shape, - onClick = { - onValueChanged(chipItem.value) - } - ) - } - } -} - -data class ChipItem( - val text: String, - val value: T -) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt new file mode 100644 index 0000000..1ae57c1 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt @@ -0,0 +1,35 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import com.valentinilk.shimmer.shimmer + +@Composable +fun ShimmerHost( + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit +) { + Column( + horizontalAlignment = horizontalAlignment, + modifier = modifier + .shimmer() + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), + blendMode = BlendMode.DstIn + ) + }, + content = content + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TopAppBar.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TopAppBar.kt deleted file mode 100644 index 238d304..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TopAppBar.kt +++ /dev/null @@ -1,23 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier - -@Composable -inline fun TopAppBar( - modifier: Modifier = Modifier, - content: @Composable RowScope.() -> Unit -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = modifier - .fillMaxWidth(), - content = content - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt index 40471e8..811fd51 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,7 +24,6 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -36,7 +34,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset @@ -51,10 +48,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import it.vfsfitvnm.vimusic.ui.components.ChunkyButton import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.drawCircle import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.secondary @@ -95,9 +90,7 @@ fun TextFieldDialog( ) { BasicTextField( value = textFieldValue, - onValueChange = { - textFieldValue = it - }, + onValueChange = { textFieldValue = it }, textStyle = typography.xs.semiBold.center, singleLine = singleLine, maxLines = maxLines, @@ -144,19 +137,14 @@ fun TextFieldDialog( modifier = Modifier .fillMaxWidth() ) { - ChunkyButton( - backgroundColor = Color.Transparent, + DialogTextButton( text = cancelText, - textStyle = typography.xs.semiBold, - shape = RoundedCornerShape(36.dp), onClick = onCancel ) - ChunkyButton( - backgroundColor = colorPalette.accent, + DialogTextButton( + primary = true, text = doneText, - textStyle = typography.xs.semiBold.color(colorPalette.onAccent), - shape = RoundedCornerShape(36.dp), onClick = { if (isTextInputValid(textFieldValue.text)) { onDismiss() @@ -183,7 +171,7 @@ fun ConfirmationDialog( confirmText: String = "Confirm", onCancel: () -> Unit = onDismiss ) { - val (colorPalette, typography) = LocalAppearance.current + val (_, typography) = LocalAppearance.current DefaultDialog( onDismiss = onDismiss, @@ -191,7 +179,7 @@ fun ConfirmationDialog( ) { BasicText( text = text, - style = typography.xs.semiBold.center, + style = typography.xs.medium.center, modifier = Modifier .padding(all = 16.dp) ) @@ -201,19 +189,14 @@ fun ConfirmationDialog( modifier = Modifier .fillMaxWidth() ) { - ChunkyButton( - backgroundColor = Color.Transparent, + DialogTextButton( text = cancelText, - textStyle = typography.xs.semiBold, - shape = RoundedCornerShape(36.dp), onClick = onCancel ) - ChunkyButton( - backgroundColor = colorPalette.accent, + DialogTextButton( text = confirmText, - textStyle = typography.xs.semiBold.color(colorPalette.onAccent), - shape = RoundedCornerShape(36.dp), + primary = true, onClick = { onConfirm() onDismiss() @@ -267,10 +250,7 @@ inline fun ValueSelectorDialog( Column( modifier = modifier .padding(all = 48.dp) - .background( - color = colorPalette.background1, - shape = RoundedCornerShape(8.dp) - ) + .background(color = colorPalette.background1, shape = RoundedCornerShape(8.dp)) .padding(vertical = 16.dp), ) { BasicText( @@ -290,8 +270,6 @@ inline fun ValueSelectorDialog( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, onClick = { onDismiss() onValueSelected(value) @@ -340,20 +318,17 @@ inline fun ValueSelectorDialog( } } - BasicText( - text = "Cancel", - style = typography.xs.semiBold, + Box( modifier = Modifier - .padding(horizontal = 24.dp) - .clip(RoundedCornerShape(36.dp)) - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onDismiss - ) - .padding(horizontal = 24.dp, vertical = 16.dp) .align(Alignment.End) - ) + .padding(end = 24.dp) + ) { + DialogTextButton( + text = "Cancel", + onClick = onDismiss, + modifier = Modifier + ) + } } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DialogTextButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DialogTextButton.kt new file mode 100644 index 0000000..c3bedcf --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DialogTextButton.kt @@ -0,0 +1,42 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.medium + +@Composable +fun DialogTextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + primary: Boolean = false, +) { + val (colorPalette, typography) = LocalAppearance.current + + val textColor = when { + !enabled -> colorPalette.textDisabled + primary -> colorPalette.onAccent + else -> colorPalette.text + } + + BasicText( + text = text, + style = typography.xs.medium.color(textColor), + modifier = modifier + .clip(RoundedCornerShape(36.dp)) + .background(if (primary) colorPalette.accent else Color.Transparent) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 20.dp, vertical = 16.dp) + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DropDownSection.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DropDownSection.kt deleted file mode 100644 index 6ce771e..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DropDownSection.kt +++ /dev/null @@ -1,106 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.medium - -@Composable -fun DropDownSection(content: @Composable ColumnScope.() -> Unit) { - val (colorPalette) = LocalAppearance.current - Column( - modifier = Modifier - .shadow( - elevation = 2.dp, - shape = RoundedCornerShape(16.dp) - ) - .background(colorPalette.background1) - .width(IntrinsicSize.Max), - content = content - ) -} - -@Composable -fun DropDownSectionSpacer() { - Spacer( - modifier = Modifier - .height(4.dp) - ) -} - -@Composable -fun DropDownTextItem( - text: String, - isSelected: Boolean, - onClick: () -> Unit -) { - val (colorPalette) = LocalAppearance.current - - DropDownTextItem( - text = text, - textColor = if (isSelected) { - colorPalette.onAccent - } else { - colorPalette.textSecondary - }, - backgroundColor = if (isSelected) { - colorPalette.accent - } else { - colorPalette.background1 - }, - onClick = onClick - ) -} - -@Composable -fun DropDownTextItem( - text: String, - backgroundColor: Color? = null, - textColor: Color? = null, - onClick: () -> Unit -) { - val (colorPalette, typography) = LocalAppearance.current - - BasicText( - text = text, - style = typography.xxs.medium.copy( - color = textColor ?: colorPalette.text, - letterSpacing = 1.sp - ), - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick - ) - .background(backgroundColor ?: colorPalette.background1) - .fillMaxWidth() - .widthIn(min = 124.dp, max = 248.dp) - .padding( - horizontal = 16.dp, - vertical = 8.dp - ) - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DropdownMenu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DropdownMenu.kt deleted file mode 100644 index bd86c6d..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DropdownMenu.kt +++ /dev/null @@ -1,205 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties -import kotlin.math.max -import kotlin.math.min - -@Composable -fun DropdownMenu( - isDisplayed: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 0.dp), - properties: PopupProperties = PopupProperties(focusable = true), - content: @Composable ColumnScope.() -> Unit -) { - val expandedStates = remember { - MutableTransitionState(false) - }.apply { targetState = isDisplayed } - - if (expandedStates.currentState || expandedStates.targetState) { - val density = LocalDensity.current - - var transformOrigin by remember { - mutableStateOf(TransformOrigin.Center) - } - - val popupPositionProvider = - DropdownMenuPositionProvider(offset, density) { parentBounds, menuBounds -> - transformOrigin = calculateTransformOrigin(parentBounds, menuBounds) - } - - Popup( - onDismissRequest = onDismissRequest, - popupPositionProvider = popupPositionProvider, - properties = properties - ) { - DropdownMenuContent( - expandedStates = expandedStates, - transformOrigin = transformOrigin, - modifier = modifier, - content = content - ) - } - } -} - -@Composable -internal fun DropdownMenuContent( - expandedStates: MutableTransitionState, - transformOrigin: TransformOrigin, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit -) { - val transition = updateTransition(expandedStates, "DropDownMenu") - - val scale by transition.animateFloat( - transitionSpec = { - if (false isTransitioningTo true) { - // Dismissed to expanded - tween( - durationMillis = 128, - easing = LinearOutSlowInEasing - ) - } else { - // Expanded to dismissed. - tween( - durationMillis = 64, - delayMillis = 64 - ) - } - }, label = "" - ) { isDisplayed -> - if (isDisplayed) 1f else 0.9f - } - - Column( - modifier = modifier - .graphicsLayer { - scaleX = scale - scaleY = scale - this.transformOrigin = transformOrigin - }, - content = content, - ) -} - -@Immutable -private data class DropdownMenuPositionProvider( - val contentOffset: DpOffset, - val density: Density, - val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> } -) : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset { - // The min margin above and below the menu, relative to the screen. - val verticalMargin = with(density) { 48.dp.roundToPx() } - // The content offset specified using the dropdown offset parameter. - val contentOffsetX = with(density) { contentOffset.x.roundToPx() } - val contentOffsetY = with(density) { contentOffset.y.roundToPx() } - - // Compute horizontal position. - val toRight = anchorBounds.left + contentOffsetX - val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width - val toDisplayRight = windowSize.width - popupContentSize.width - val toDisplayLeft = 0 - val x = if (layoutDirection == LayoutDirection.Ltr) { - sequenceOf( - toRight, - toLeft, - // If the anchor gets outside of the window on the left, we want to position - // toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight. - if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft - ) - } else { - sequenceOf( - toLeft, - toRight, - // If the anchor gets outside of the window on the right, we want to position - // toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft. - if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight - ) - }.firstOrNull { - it >= 0 && it + popupContentSize.width <= windowSize.width - } ?: toLeft - - // Compute vertical position. - val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin) - val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height - val toCenter = anchorBounds.top - popupContentSize.height / 2 - val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin - val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull { - it >= verticalMargin && - it + popupContentSize.height <= windowSize.height - verticalMargin - } ?: toTop - - onPositionCalculated( - anchorBounds, - IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height) - ) - return IntOffset(x, y) - } -} - -fun calculateTransformOrigin( - parentBounds: IntRect, - menuBounds: IntRect -): TransformOrigin { - val pivotX = when { - menuBounds.left >= parentBounds.right -> 0f - menuBounds.right <= parentBounds.left -> 1f - menuBounds.width == 0 -> 0f - else -> { - val intersectionCenter = - ( - max(parentBounds.left, menuBounds.left) + - min(parentBounds.right, menuBounds.right) - ) / 2 - (intersectionCenter - menuBounds.left).toFloat() / menuBounds.width - } - } - val pivotY = when { - menuBounds.top >= parentBounds.bottom -> 0f - menuBounds.bottom <= parentBounds.top -> 1f - menuBounds.height == 0 -> 0f - else -> { - val intersectionCenter = - ( - max(parentBounds.top, menuBounds.top) + - min(parentBounds.bottom, menuBounds.bottom) - ) / 2 - (intersectionCenter - menuBounds.top).toFloat() / menuBounds.height - } - } - return TransformOrigin(pivotX, pivotY) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/FloatingActionsContainer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/FloatingActionsContainer.kt new file mode 100644 index 0000000..8b0b57c --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/FloatingActionsContainer.kt @@ -0,0 +1,169 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.utils.ScrollingInfo +import it.vfsfitvnm.vimusic.utils.scrollingInfo +import it.vfsfitvnm.vimusic.utils.smoothScrollToTop +import kotlinx.coroutines.launch + +@ExperimentalAnimationApi +@Composable +fun BoxScope.FloatingActionsContainerWithScrollToTop( + lazyGridState: LazyGridState, + modifier: Modifier = Modifier, + visible: Boolean = true, + iconId: Int? = null, + onClick: (() -> Unit)? = null, + windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current +) { + val transitionState = remember { + MutableTransitionState(ScrollingInfo()) + }.apply { targetState = if (visible) lazyGridState.scrollingInfo() else null } + + FloatingActions( + transitionState = transitionState, + onScrollToTop = lazyGridState::smoothScrollToTop, + iconId = iconId, + onClick = onClick, + windowInsets = windowInsets, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun BoxScope.FloatingActionsContainerWithScrollToTop( + lazyListState: LazyListState, + modifier: Modifier = Modifier, + visible: Boolean = true, + iconId: Int? = null, + onClick: (() -> Unit)? = null, + windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current +) { + val transitionState = remember { + MutableTransitionState(ScrollingInfo()) + }.apply { targetState = if (visible) lazyListState.scrollingInfo() else null } + + FloatingActions( + transitionState = transitionState, + onScrollToTop = lazyListState::smoothScrollToTop, + iconId = iconId, + onClick = onClick, + windowInsets = windowInsets, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun BoxScope.FloatingActionsContainerWithScrollToTop( + scrollState: ScrollState, + modifier: Modifier = Modifier, + visible: Boolean = true, + iconId: Int? = null, + onClick: (() -> Unit)? = null, + windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current +) { + val transitionState = remember { + MutableTransitionState(ScrollingInfo()) + }.apply { targetState = if (visible) scrollState.scrollingInfo() else null } + + FloatingActions( + transitionState = transitionState, + iconId = iconId, + onClick = onClick, + windowInsets = windowInsets, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun BoxScope.FloatingActions( + transitionState: MutableTransitionState, + windowInsets: WindowInsets, + modifier: Modifier = Modifier, + onScrollToTop: (suspend () -> Unit)? = null, + iconId: Int? = null, + onClick: (() -> Unit)? = null +) { + val transition = updateTransition(transitionState, "") + + val bottomPaddingValues = windowInsets.only(WindowInsetsSides.Bottom).asPaddingValues() + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Bottom, + modifier = modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp) + .padding(windowInsets.only(WindowInsetsSides.End).asPaddingValues()) + ) { + onScrollToTop?.let { + transition.AnimatedVisibility( + visible = { it?.isScrollingDown == false && it.isFar }, + enter = slideInVertically(tween(500, if (iconId == null) 0 else 100)) { it }, + exit = slideOutVertically(tween(500, 0)) { it }, + ) { + val coroutineScope = rememberCoroutineScope() + + SecondaryButton( + onClick = { + coroutineScope.launch { + onScrollToTop() + } + }, + enabled = transition.targetState?.isScrollingDown == false && transition.targetState?.isFar == true, + iconId = R.drawable.chevron_up, + modifier = Modifier + .padding(bottom = 16.dp) + .padding(bottomPaddingValues) + ) + } + } + + iconId?.let { + onClick?.let { + transition.AnimatedVisibility( + visible = { it?.isScrollingDown == false }, + enter = slideInVertically(tween(500, 0)) { it }, + exit = slideOutVertically(tween(500, 100)) { it }, + ) { + PrimaryButton( + iconId = iconId, + onClick = onClick, + enabled = transition.targetState?.isScrollingDown == false, + modifier = Modifier + .padding(bottom = 16.dp) + .padding(bottomPaddingValues) + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt new file mode 100644 index 0000000..0f4a05c --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt @@ -0,0 +1,106 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.shimmer +import it.vfsfitvnm.vimusic.utils.medium +import kotlin.random.Random + +@Composable +fun Header( + title: String, + modifier: Modifier = Modifier, + actionsContent: @Composable RowScope.() -> Unit = {}, +) { + val typography = LocalAppearance.current.typography + + Header( + modifier = modifier, + titleContent = { + BasicText( + text = title, + style = typography.xxl.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actionsContent = actionsContent + ) +} + +@Composable +fun Header( + modifier: Modifier = Modifier, + titleContent: @Composable ColumnScope.() -> Unit, + actionsContent: @Composable RowScope.() -> Unit, +) { + Column( + horizontalAlignment = Alignment.End, + modifier = modifier + .padding(horizontal = 16.dp) + .height(Dimensions.headerHeight) + .fillMaxWidth() + ) { + Spacer( + modifier = Modifier + .height(48.dp), + ) + + titleContent() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .height(48.dp), + content = actionsContent, + ) + } +} + +@Composable +fun HeaderPlaceholder( + modifier: Modifier = Modifier, +) { + val (colorPalette, typography) = LocalAppearance.current + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.Center, + modifier = modifier + .padding(horizontal = 16.dp) + .height(128.dp) + .fillMaxWidth() + ) { + Box( + modifier = Modifier + .background(colorPalette.shimmer) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) + ) { + BasicText( + text = "", + style = typography.xxl.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/IconButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/IconButton.kt new file mode 100644 index 0000000..a5558eb --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/IconButton.kt @@ -0,0 +1,62 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.Indication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +@Composable +fun HeaderIconButton( + onClick: () -> Unit, + @DrawableRes icon: Int, + color: Color, + modifier: Modifier = Modifier, + enabled: Boolean = true, + indication: Indication? = null +) { + IconButton( + icon = icon, + color = color, + onClick = onClick, + enabled = enabled, + indication = indication, + modifier = modifier + .padding(all = 4.dp) + .size(18.dp) + ) +} + +@Composable +fun IconButton( + onClick: () -> Unit, + @DrawableRes icon: Int, + color: Color, + modifier: Modifier = Modifier, + enabled: Boolean = true, + indication: Indication? = null +) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier + .clickable( + indication = indication ?: rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClick = onClick + ) + .then(modifier) + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LayoutWithAdaptiveThumbnail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LayoutWithAdaptiveThumbnail.kt new file mode 100644 index 0000000..8c362de --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LayoutWithAdaptiveThumbnail.kt @@ -0,0 +1,70 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.ui.styling.shimmer +import it.vfsfitvnm.vimusic.utils.isLandscape +import it.vfsfitvnm.vimusic.utils.thumbnail + +@Composable +inline fun LayoutWithAdaptiveThumbnail( + thumbnailContent: @Composable () -> Unit, + content: @Composable () -> Unit +) { + val isLandscape = isLandscape + + if (isLandscape) { + Row(verticalAlignment = Alignment.CenterVertically) { + thumbnailContent() + content() + } + } else { + content() + } +} + +fun adaptiveThumbnailContent( + isLoading: Boolean, + url: String?, + shape: Shape? = null +): @Composable () -> Unit = { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + BoxWithConstraints(contentAlignment = Alignment.Center) { + val thumbnailSizeDp = if (isLandscape) (maxHeight - 128.dp) else (maxWidth - 64.dp) + val thumbnailSizePx = thumbnailSizeDp.px + + val modifier = Modifier + .padding(all = 16.dp) + .clip(shape ?: thumbnailShape) + .size(thumbnailSizeDp) + + if (isLoading) { + Spacer( + modifier = modifier + .shimmer() + .background(colorPalette.shimmer) + ) + } else { + AsyncImage( + model = url?.thumbnail(thumbnailSizePx), + contentDescription = null, + modifier = modifier + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LoadingOrError.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LoadingOrError.kt deleted file mode 100644 index d81efa8..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LoadingOrError.kt +++ /dev/null @@ -1,41 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.vimusic.R - -@Composable -fun LoadingOrError( - errorMessage: String? = null, - onRetry: (() -> Unit)? = null, - horizontalAlignment: Alignment.Horizontal = Alignment.Start, - loadingContent: @Composable ColumnScope.() -> Unit -) { - Box { - Column( - horizontalAlignment = horizontalAlignment, - modifier = Modifier - .alpha(if (errorMessage == null) 1f else 0f) - .shimmer(), - content = loadingContent - ) - - errorMessage?.let { - TextCard( - icon = R.drawable.alert_circle, - onClick = onRetry, - modifier = Modifier - .align(Alignment.Center) - ) { - Title(text = onRetry?.let { "Tap to retry" } ?: "Error") - Text(text = "An error has occurred:\n$errorMessage") - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt index b10c0ce..b361020 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt @@ -1,21 +1,26 @@ package it.vfsfitvnm.vimusic.ui.components.themed +import android.content.Intent import android.text.format.DateUtils +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween import androidx.compose.animation.with +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -28,13 +33,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.route.empty import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R @@ -42,53 +47,34 @@ import it.vfsfitvnm.vimusic.enums.PlaylistSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.DetailedSong import it.vfsfitvnm.vimusic.models.Playlist +import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.SongPlaylistMap import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.transaction -import it.vfsfitvnm.vimusic.ui.components.ChunkyButton -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.artistRoute -import it.vfsfitvnm.vimusic.ui.screens.viewPlaylistsRoute +import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.favoritesIcon +import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.addNext import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.shareAsYouTubeSong import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOf -@ExperimentalAnimationApi -@Composable -fun InFavoritesMediaItemMenu( - song: DetailedSong, - modifier: Modifier = Modifier, - onDismiss: (() -> Unit)? = null -) { - NonQueuedMediaItemMenu( - mediaItem = song.asMediaItem, - onDismiss = onDismiss, - onRemoveFromFavorites = { - query { - Database.like(song.id, null) - } - }, - modifier = modifier - ) -} - @ExperimentalAnimationApi @Composable fun InHistoryMediaItemMenu( + onDismiss: () -> Unit, song: DetailedSong, - modifier: Modifier = Modifier, - onDismiss: (() -> Unit)? = null + modifier: Modifier = Modifier ) { - val menuState = LocalMenuState.current val binder = LocalPlayerServiceBinder.current var isHiding by remember { @@ -100,7 +86,7 @@ fun InHistoryMediaItemMenu( text = "Do you really hide this song? Its playback time and cache will be wiped.\nThis action is irreversible.", onDismiss = { isHiding = false }, onConfirm = { - (onDismiss ?: menuState::hide).invoke() + onDismiss() query { // Not sure we can to this here binder?.cache?.removeResource(song.id) @@ -121,11 +107,11 @@ fun InHistoryMediaItemMenu( @ExperimentalAnimationApi @Composable fun InPlaylistMediaItemMenu( + onDismiss: () -> Unit, playlistId: Long, positionInPlaylist: Int, song: DetailedSong, - modifier: Modifier = Modifier, - onDismiss: (() -> Unit)? = null + modifier: Modifier = Modifier ) { NonQueuedMediaItemMenu( mediaItem = song.asMediaItem, @@ -143,19 +129,17 @@ fun InPlaylistMediaItemMenu( @ExperimentalAnimationApi @Composable fun NonQueuedMediaItemMenu( + onDismiss: () -> Unit, mediaItem: MediaItem, modifier: Modifier = Modifier, - onDismiss: (() -> Unit)? = null, onRemoveFromPlaylist: (() -> Unit)? = null, onHideFromDatabase: (() -> Unit)? = null, - onRemoveFromFavorites: (() -> Unit)? = null, ) { - val menuState = LocalMenuState.current val binder = LocalPlayerServiceBinder.current BaseMediaItemMenu( mediaItem = mediaItem, - onDismiss = onDismiss ?: menuState::hide, + onDismiss = onDismiss, onStartRadio = { binder?.stopRadio() binder?.player?.forcePlay(mediaItem) @@ -170,7 +154,6 @@ fun NonQueuedMediaItemMenu( onEnqueue = { binder?.player?.enqueue(mediaItem) }, onRemoveFromPlaylist = onRemoveFromPlaylist, onHideFromDatabase = onHideFromDatabase, - onRemoveFromFavorites = onRemoveFromFavorites, modifier = modifier ) } @@ -178,22 +161,19 @@ fun NonQueuedMediaItemMenu( @ExperimentalAnimationApi @Composable fun QueuedMediaItemMenu( + onDismiss: () -> Unit, mediaItem: MediaItem, indexInQueue: Int?, - modifier: Modifier = Modifier, - onDismiss: (() -> Unit)? = null, - onGlobalRouteEmitted: (() -> Unit)? = null + modifier: Modifier = Modifier ) { - val menuState = LocalMenuState.current val binder = LocalPlayerServiceBinder.current BaseMediaItemMenu( mediaItem = mediaItem, - onDismiss = onDismiss ?: menuState::hide, + onDismiss = onDismiss, onRemoveFromQueue = if (indexInQueue != null) ({ binder?.player?.removeMediaItem(indexInQueue) }) else null, - onGlobalRouteEmitted = onGlobalRouteEmitted, modifier = modifier ) } @@ -201,19 +181,17 @@ fun QueuedMediaItemMenu( @ExperimentalAnimationApi @Composable fun BaseMediaItemMenu( - mediaItem: MediaItem, onDismiss: () -> Unit, + mediaItem: MediaItem, modifier: Modifier = Modifier, onGoToEqualizer: (() -> Unit)? = null, - onSetSleepTimer: (() -> Unit)? = null, + onShowSleepTimer: (() -> Unit)? = null, onStartRadio: (() -> Unit)? = null, onPlayNext: (() -> Unit)? = null, onEnqueue: (() -> Unit)? = null, onRemoveFromQueue: (() -> Unit)? = null, onRemoveFromPlaylist: (() -> Unit)? = null, onHideFromDatabase: (() -> Unit)? = null, - onRemoveFromFavorites: (() -> Unit)? = null, - onGlobalRouteEmitted: (() -> Unit)? = null, ) { val context = LocalContext.current @@ -221,7 +199,7 @@ fun BaseMediaItemMenu( mediaItem = mediaItem, onDismiss = onDismiss, onGoToEqualizer = onGoToEqualizer, - onSetSleepTimer = onSetSleepTimer, + onShowSleepTimer = onShowSleepTimer, onStartRadio = onStartRadio, onPlayNext = onPlayNext, onEnqueue = onEnqueue, @@ -238,15 +216,22 @@ fun BaseMediaItemMenu( } }, onHideFromDatabase = onHideFromDatabase, - onRemoveFromFavorites = onRemoveFromFavorites, onRemoveFromPlaylist = onRemoveFromPlaylist, onRemoveFromQueue = onRemoveFromQueue, onGoToAlbum = albumRoute::global, onGoToArtist = artistRoute::global, onShare = { - context.shareAsYouTubeSong(mediaItem) + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/watch?v=${mediaItem.mediaId}" + ) + } + + context.startActivity(Intent.createChooser(sendIntent, null)) }, - onGlobalRouteEmitted = onGlobalRouteEmitted, modifier = modifier ) } @@ -254,399 +239,436 @@ fun BaseMediaItemMenu( @ExperimentalAnimationApi @Composable fun MediaItemMenu( - mediaItem: MediaItem, onDismiss: () -> Unit, + mediaItem: MediaItem, modifier: Modifier = Modifier, onGoToEqualizer: (() -> Unit)? = null, - onSetSleepTimer: (() -> Unit)? = null, + onShowSleepTimer: (() -> Unit)? = null, onStartRadio: (() -> Unit)? = null, onPlayNext: (() -> Unit)? = null, onEnqueue: (() -> Unit)? = null, onHideFromDatabase: (() -> Unit)? = null, onRemoveFromQueue: (() -> Unit)? = null, - onRemoveFromFavorites: (() -> Unit)? = null, onRemoveFromPlaylist: (() -> Unit)? = null, onAddToPlaylist: ((Playlist, Int) -> Unit)? = null, onGoToAlbum: ((String) -> Unit)? = null, onGoToArtist: ((String) -> Unit)? = null, - onShare: (() -> Unit)? = null, - onGlobalRouteEmitted: (() -> Unit)? = null, + onShare: () -> Unit ) { - Menu(modifier = modifier) { - RouteHandler( - transitionSpec = { - when (targetState.route) { - viewPlaylistsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with - slideOutOfContainer(AnimatedContentScope.SlideDirection.Left) - else -> when (initialState.route) { - viewPlaylistsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with - slideOutOfContainer(AnimatedContentScope.SlideDirection.Right) - else -> empty + val (colorPalette) = LocalAppearance.current + val density = LocalDensity.current + + var isViewingPlaylists by remember { + mutableStateOf(false) + } + + var height by remember { + mutableStateOf(0.dp) + } + + val likedAt by remember(mediaItem.mediaId) { + Database.likedAt(mediaItem.mediaId).distinctUntilChanged() + }.collectAsState(initial = null, context = Dispatchers.IO) + + AnimatedContent( + targetState = isViewingPlaylists, + transitionSpec = { + val animationSpec = tween(400) + val slideDirection = if (targetState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right + + slideIntoContainer(slideDirection, animationSpec) with + slideOutOfContainer(slideDirection, animationSpec) + } + ) { currentIsViewingPlaylists -> + if (currentIsViewingPlaylists) { + val playlistPreviews by remember { + Database.playlistPreviews(PlaylistSortBy.DateAdded, SortOrder.Descending) + }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + + var isCreatingNewPlaylist by rememberSaveable { + mutableStateOf(false) + } + + if (isCreatingNewPlaylist && onAddToPlaylist != null) { + TextFieldDialog( + hintText = "Enter the playlist name", + onDismiss = { isCreatingNewPlaylist = false }, + onDone = { text -> + onDismiss() + onAddToPlaylist(Playlist(name = text), 0) + } + ) + } + + BackHandler { + isViewingPlaylists = false + } + + Menu( + modifier = modifier + .requiredHeight(height) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth() + ) { + IconButton( + onClick = { isViewingPlaylists = false }, + icon = R.drawable.chevron_back, + color = colorPalette.textSecondary, + modifier = Modifier + .padding(all = 4.dp) + .size(20.dp) + ) + + if (onAddToPlaylist != null) { + SecondaryTextButton( + text = "New playlist", + onClick = { isCreatingNewPlaylist = true }, + alternative = true + ) + } + } + + onAddToPlaylist?.let { onAddToPlaylist -> + playlistPreviews.forEach { playlistPreview -> + MenuEntry( + icon = R.drawable.playlist, + text = playlistPreview.playlist.name, + secondaryText = "${playlistPreview.songCount} songs", + onClick = { + onDismiss() + onAddToPlaylist( + playlistPreview.playlist, + playlistPreview.songCount + ) + } + ) } } } - ) { - viewPlaylistsRoute { - val playlistPreviews by remember { - Database.playlistPreviews(PlaylistSortBy.DateAdded, SortOrder.Descending) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + } else { + Menu( + modifier = modifier + .onPlaced { height = with(density) { it.size.height.toDp() } } + ) { + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px - var isCreatingNewPlaylist by rememberSaveable { - mutableStateOf(false) - } + SongItem( + song = mediaItem, + thumbnailSizeDp = thumbnailSizeDp, + thumbnailSizePx = thumbnailSizePx, + trailingContent = { + IconButton( + icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, + color = colorPalette.favoritesIcon, + onClick = { + query { + if (Database.like( + mediaItem.mediaId, + if (likedAt == null) System.currentTimeMillis() else null + ) == 0 + ) { + Database.insert(mediaItem, Song::toggleLike) + } + } + }, + modifier = Modifier + .padding(all = 4.dp) + .size(18.dp) + ) + }, + modifier = Modifier + .clickable(onClick = onShare) + ) - if (isCreatingNewPlaylist && onAddToPlaylist != null) { - TextFieldDialog( - hintText = "Enter the playlist name", - onDismiss = { - isCreatingNewPlaylist = false - }, - onDone = { text -> + Spacer( + modifier = Modifier + .height(8.dp) + ) + + Spacer( + modifier = Modifier + .alpha(0.5f) + .align(Alignment.CenterHorizontally) + .background(colorPalette.textDisabled) + .height(1.dp) + .fillMaxWidth(1f) + ) + + Spacer( + modifier = Modifier + .height(8.dp) + ) + + onStartRadio?.let { onStartRadio -> + MenuEntry( + icon = R.drawable.radio, + text = "Start radio", + onClick = { onDismiss() - onAddToPlaylist(Playlist(name = text), 0) + onStartRadio() } ) } - Column { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - ) { - MenuBackButton(onClick = pop) + onPlayNext?.let { onPlayNext -> + MenuEntry( + icon = R.drawable.play_skip_forward, + text = "Play next", + onClick = { + onDismiss() + onPlayNext() + } + ) + } - if (onAddToPlaylist != null) { - MenuIconButton( - icon = R.drawable.add, - onClick = { - isCreatingNewPlaylist = true + onEnqueue?.let { onEnqueue -> + MenuEntry( + icon = R.drawable.enqueue, + text = "Enqueue", + onClick = { + onDismiss() + onEnqueue() + } + ) + } + + onGoToEqualizer?.let { onGoToEqualizer -> + MenuEntry( + icon = R.drawable.equalizer, + text = "Equalizer", + onClick = { + onDismiss() + onGoToEqualizer() + } + ) + } + + // TODO: find solution to this shit + onShowSleepTimer?.let { + val binder = LocalPlayerServiceBinder.current + val (_, typography) = LocalAppearance.current + + var isShowingSleepTimerDialog by remember { + mutableStateOf(false) + } + + val sleepTimerMillisLeft by (binder?.sleepTimerMillisLeft + ?: flowOf(null)) + .collectAsState(initial = null) + + if (isShowingSleepTimerDialog) { + if (sleepTimerMillisLeft != null) { + ConfirmationDialog( + text = "Do you want to stop the sleep timer?", + cancelText = "No", + confirmText = "Stop", + onDismiss = { + isShowingSleepTimerDialog = false + onDismiss() + }, + onConfirm = { + binder?.cancelSleepTimer() + onDismiss() } ) + } else { + DefaultDialog(onDismiss = { + isShowingSleepTimerDialog = false + }) { + var amount by remember { + mutableStateOf(1) + } + + BasicText( + text = "Set sleep timer", + style = typography.s.semiBold, + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 24.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + space = 16.dp, + alignment = Alignment.CenterHorizontally + ), + modifier = Modifier + .padding(vertical = 16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .alpha(if (amount <= 1) 0.5f else 1f) + .clip(CircleShape) + .clickable(enabled = amount > 1) { amount-- } + .size(48.dp) + .background(colorPalette.background0) + ) { + BasicText( + text = "-", + style = typography.xs.semiBold + ) + } + + Box(contentAlignment = Alignment.Center) { + BasicText( + text = "88h 88m", + style = typography.s.semiBold, + modifier = Modifier + .alpha(0f) + ) + BasicText( + text = "${amount / 6}h ${(amount % 6) * 10}m", + style = typography.s.semiBold + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .alpha(if (amount >= 60) 0.5f else 1f) + .clip(CircleShape) + .clickable(enabled = amount < 60) { amount++ } + .size(48.dp) + .background(colorPalette.background0) + ) { + BasicText( + text = "+", + style = typography.xs.semiBold + ) + } + } + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + ) { + DialogTextButton( + text = "Cancel", + onClick = { + isShowingSleepTimerDialog = false + onDismiss() + } + ) + + DialogTextButton( + text = "Set", + enabled = amount > 0, + primary = true, + onClick = { + binder?.startSleepTimer(amount * 10 * 60 * 1000L) + isShowingSleepTimerDialog = false + onDismiss() + } + ) + } + } } } - onAddToPlaylist?.let { onAddToPlaylist -> - if (onRemoveFromFavorites == null) { - MenuEntry( - icon = R.drawable.heart, - text = "Favorites", - onClick = { - onDismiss() - query { - Database.insert(mediaItem) - Database.like(mediaItem.mediaId, System.currentTimeMillis()) - } - } - ) + MenuEntry( + icon = R.drawable.alarm, + text = "Sleep timer", + secondaryText = sleepTimerMillisLeft?.let { + "${ + DateUtils.formatElapsedTime( + it / 1000 + ) + } left" + }, + onClick = { + isShowingSleepTimerDialog = true } + ) + } - playlistPreviews.forEach { playlistPreview -> - MenuEntry( - icon = R.drawable.playlist, - text = playlistPreview.playlist.name, - secondaryText = "${playlistPreview.songCount} songs", - onClick = { - onDismiss() - onAddToPlaylist( - playlistPreview.playlist, - playlistPreview.songCount - ) - } + if (onAddToPlaylist != null) { + MenuEntry( + icon = R.drawable.playlist, + text = "Add to playlist", + onClick = { isViewingPlaylists = true }, + trailingContent = { + Image( + painter = painterResource(R.drawable.chevron_forward), + contentDescription = null, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(colorPalette.textSecondary), + modifier = Modifier + .size(16.dp) ) } + ) + } + + onGoToAlbum?.let { onGoToAlbum -> + mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> + MenuEntry( + icon = R.drawable.disc, + text = "Go to album", + onClick = { + onDismiss() + onGoToAlbum(albumId) + } + ) } } - } - host { - Column( - modifier = Modifier - .pointerInput(Unit) { - detectTapGestures { } - } - ) { - onStartRadio?.let { onStartRadio -> - MenuEntry( - icon = R.drawable.radio, - text = "Start radio", - onClick = { - onDismiss() - onStartRadio() - } - ) - } - - onPlayNext?.let { onPlayNext -> - MenuEntry( - icon = R.drawable.play_skip_forward, - text = "Play next", - onClick = { - onDismiss() - onPlayNext() - } - ) - } - - onEnqueue?.let { onEnqueue -> - MenuEntry( - icon = R.drawable.enqueue, - text = "Enqueue", - onClick = { - onDismiss() - onEnqueue() - } - ) - } - - onGoToEqualizer?.let { onGoToEqualizer -> - MenuEntry( - icon = R.drawable.equalizer, - text = "Equalizer", - onClick = { - onDismiss() - onGoToEqualizer() - } - ) - } - - onSetSleepTimer?.let { - val binder = LocalPlayerServiceBinder.current - val (colorPalette, typography) = LocalAppearance.current - - var isShowingSleepTimerDialog by remember { - mutableStateOf(false) - } - - val sleepTimerMillisLeft by (binder?.sleepTimerMillisLeft ?: flowOf(null)) - .collectAsState(initial = null) - - if (isShowingSleepTimerDialog) { - if (sleepTimerMillisLeft != null) { - ConfirmationDialog( - text = "Do you want to stop the sleep timer?", - cancelText = "No", - confirmText = "Stop", - onDismiss = { - isShowingSleepTimerDialog = false - }, - onConfirm = { - binder?.cancelSleepTimer() - } - ) - } else { - DefaultDialog( - onDismiss = { - isShowingSleepTimerDialog = false - } - ) { - var amount by remember { - mutableStateOf(1) - } - - BasicText( - text = "Set sleep timer", - style = typography.s.semiBold, - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 24.dp) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - space = 16.dp, - alignment = Alignment.CenterHorizontally - ), - modifier = Modifier - .padding(vertical = 16.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .alpha(if (amount <= 1) 0.5f else 1f) - .clip(CircleShape) - .clickable(enabled = amount > 1) { amount-- } - .size(48.dp) - .background(colorPalette.background0) - ) { - BasicText( - text = "-", - style = typography.xs.semiBold - ) - } - - Box(contentAlignment = Alignment.Center) { - BasicText( - text = "88h 88m", - style = typography.s.semiBold, - modifier = Modifier - .alpha(0f) - ) - BasicText( - text = "${amount / 6}h ${(amount % 6) * 10}m", - style = typography.s.semiBold - ) - } - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .alpha(if (amount >= 60) 0.5f else 1f) - .clip(CircleShape) - .clickable(enabled = amount < 60) { amount++ } - .size(48.dp) - .background(colorPalette.background0) - ) { - BasicText( - text = "+", - style = typography.xs.semiBold - ) - } - } - - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() - ) { - ChunkyButton( - backgroundColor = Color.Transparent, - text = "Cancel", - textStyle = typography.xs.semiBold, - shape = RoundedCornerShape(36.dp), - onClick = { isShowingSleepTimerDialog = false } - ) - - ChunkyButton( - backgroundColor = colorPalette.accent, - text = "Set", - textStyle = typography.xs.semiBold.color(colorPalette.onAccent), - shape = RoundedCornerShape(36.dp), - isEnabled = amount > 0, - onClick = { - binder?.startSleepTimer(amount * 10 * 60 * 1000L) - isShowingSleepTimerDialog = false + onGoToArtist?.let { onGoToArtist -> + mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames") + ?.let { artistNames -> + mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds") + ?.let { artistIds -> + artistNames.zip(artistIds) + .forEach { (authorName, authorId) -> + if (authorId != null) { + MenuEntry( + icon = R.drawable.person, + text = "More of $authorName", + onClick = { + onDismiss() + onGoToArtist(authorId) + } + ) } - ) - } + } } - } } + } - MenuEntry( - icon = R.drawable.alarm, - text = "Sleep timer", - secondaryText = sleepTimerMillisLeft?.let { - "${ - DateUtils.formatElapsedTime( - it / 1000 - ) - } left" - }, - onClick = { - isShowingSleepTimerDialog = true - } - ) - } - - if (onAddToPlaylist != null) { - MenuEntry( - icon = R.drawable.playlist, - text = "Add to playlist or favorites", - onClick = { - viewPlaylistsRoute() - } - ) - } - - onGoToAlbum?.let { onGoToAlbum -> - mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> - MenuEntry( - icon = R.drawable.disc, - text = "Go to album", - onClick = { - onDismiss() - onGlobalRouteEmitted?.invoke() - onGoToAlbum(albumId) - } - ) + onRemoveFromQueue?.let { onRemoveFromQueue -> + MenuEntry( + icon = R.drawable.trash, + text = "Remove from queue", + onClick = { + onDismiss() + onRemoveFromQueue() } - } + ) + } - onGoToArtist?.let { onGoToArtist -> - mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames") - ?.let { artistNames -> - mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds") - ?.let { artistIds -> - artistNames.zip(artistIds) - .forEach { (authorName, authorId) -> - if (authorId != null) { - MenuEntry( - icon = R.drawable.person, - text = "More of $authorName", - onClick = { - onDismiss() - onGlobalRouteEmitted?.invoke() - onGoToArtist(authorId) - } - ) - } - } - } - } - } + onRemoveFromPlaylist?.let { onRemoveFromPlaylist -> + MenuEntry( + icon = R.drawable.trash, + text = "Remove from playlist", + onClick = { + onDismiss() + onRemoveFromPlaylist() + } + ) + } - onShare?.let { onShare -> - MenuEntry( - icon = R.drawable.share_social, - text = "Share", - onClick = { - onDismiss() - onShare() - } - ) - } - - onRemoveFromQueue?.let { onRemoveFromQueue -> - MenuEntry( - icon = R.drawable.trash, - text = "Remove from queue", - onClick = { - onDismiss() - onRemoveFromQueue() - } - ) - } - - onRemoveFromFavorites?.let { onRemoveFromFavorites -> - MenuEntry( - icon = R.drawable.heart_dislike, - text = "Remove from favorites", - onClick = { - onDismiss() - onRemoveFromFavorites() - } - ) - } - - onRemoveFromPlaylist?.let { onRemoveFromPlaylist -> - MenuEntry( - icon = R.drawable.trash, - text = "Remove from playlist", - onClick = { - onDismiss() - onRemoveFromPlaylist() - } - ) - } - - onHideFromDatabase?.let { onHideFromDatabase -> - MenuEntry( - icon = R.drawable.trash, - text = "Hide", - onClick = onHideFromDatabase - ) - } + onHideFromDatabase?.let { onHideFromDatabase -> + MenuEntry( + icon = R.drawable.trash, + text = "Hide", + onClick = onHideFromDatabase + ) } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt index 5ec56bc..0081f84 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt @@ -1,12 +1,11 @@ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -17,16 +16,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.secondary @@ -57,7 +53,8 @@ fun MenuEntry( text: String, onClick: () -> Unit, secondaryText: String? = null, - isEnabled: Boolean = true, + enabled: Boolean = true, + trailingContent: (@Composable () -> Unit)? = null ) { val (colorPalette, typography) = LocalAppearance.current @@ -65,14 +62,9 @@ fun MenuEntry( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - enabled = isEnabled, - onClick = onClick - ) + .clickable(enabled = enabled, onClick = onClick) .fillMaxWidth() - .alpha(if (isEnabled) 1f else 0.4f) + .alpha(if (enabled) 1f else 0.4f) .padding(horizontal = 24.dp, vertical = 16.dp) ) { Image( @@ -83,7 +75,10 @@ fun MenuEntry( .size(15.dp) ) - Column { + Column( + modifier = Modifier + .weight(1f) + ) { BasicText( text = text, style = typography.xs.medium @@ -96,41 +91,7 @@ fun MenuEntry( ) } } + + trailingContent?.invoke() } } - -@Composable -fun MenuIconButton( - @DrawableRes icon: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val (colorPalette) = LocalAppearance.current - - Box( - modifier = modifier - .padding(horizontal = 14.dp) - ) { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - } -} - -@Composable -fun MenuBackButton( - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - MenuIconButton( - icon = R.drawable.chevron_back, - onClick = onClick, - modifier = modifier - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/NavigationRail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/NavigationRail.kt new file mode 100644 index 0000000..7f37369 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/NavigationRail.kt @@ -0,0 +1,170 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.isLandscape +import it.vfsfitvnm.vimusic.utils.semiBold + +@Composable +inline fun NavigationRail( + topIconButtonId: Int, + noinline onTopIconButtonClick: () -> Unit, + tabIndex: Int, + crossinline onTabIndexChanged: (Int) -> Unit, + content: @Composable ColumnScope.(@Composable (Int, String, Int) -> Unit) -> Unit, + modifier: Modifier = Modifier +) { + val (colorPalette, typography) = LocalAppearance.current + + val isLandscape = isLandscape + + val paddingValues = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.Start).asPaddingValues() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .size( + width = if (isLandscape) Dimensions.navigationRailWidthLandscape else Dimensions.navigationRailWidth, + height = Dimensions.headerHeight + ) + ) { + Image( + painter = painterResource(topIconButtonId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textSecondary), + modifier = Modifier + .offset( + x = if (isLandscape) 0.dp else Dimensions.navigationRailIconOffset, + y = 48.dp + ) + .clip(CircleShape) + .clickable(onClick = onTopIconButtonClick) + .padding(all = 12.dp) + .size(22.dp) + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(if (isLandscape) Dimensions.navigationRailWidthLandscape else Dimensions.navigationRailWidth) + ) { + val transition = updateTransition(targetState = tabIndex, label = null) + + content { index, text, icon -> + val dothAlpha by transition.animateFloat(label = "") { + if (it == index) 1f else 0f + } + + val textColor by transition.animateColor(label = "") { + if (it == index) colorPalette.text else colorPalette.textDisabled + } + + val iconContent: @Composable () -> Unit = { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .vertical(enabled = !isLandscape) + .graphicsLayer { + alpha = dothAlpha + translationX = (1f - dothAlpha) * -48.dp.toPx() + rotationZ = if (isLandscape) 0f else -90f + } + .size(Dimensions.navigationRailIconOffset * 2) + ) + } + + val textContent: @Composable () -> Unit = { + BasicText( + text = text, + style = typography.xs.semiBold.center.color(textColor), + modifier = Modifier + .vertical(enabled = !isLandscape) + .rotate(if (isLandscape) 0f else -90f) + .padding(horizontal = 16.dp) + ) + } + + val contentModifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = { onTabIndexChanged(index) }) + + if (isLandscape) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = contentModifier + .padding(vertical = 8.dp) + ) { + iconContent() + textContent() + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = contentModifier + .padding(horizontal = 8.dp) + ) { + iconContent() + textContent() + } + } + } + } + } +} + +fun Modifier.vertical(enabled: Boolean = true) = + if (enabled) + layout { measurable, constraints -> + val placeable = measurable.measure(constraints.copy(maxWidth = Int.MAX_VALUE)) + layout(placeable.height, placeable.width) { + placeable.place( + x = -(placeable.width / 2 - placeable.height / 2), + y = -(placeable.height / 2 - placeable.width / 2) + ) + } + } else this diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/PrimaryButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/PrimaryButton.kt new file mode 100644 index 0000000..294b520 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/PrimaryButton.kt @@ -0,0 +1,44 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance + +@Composable +fun PrimaryButton( + onClick: () -> Unit, + @DrawableRes iconId: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val (colorPalette) = LocalAppearance.current + + Box( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = enabled, onClick = onClick) + .background(colorPalette.background2) + .size(62.dp) + ) { + Image( + painter = painterResource(iconId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .align(Alignment.Center) + .size(20.dp) + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt new file mode 100644 index 0000000..3f71da7 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt @@ -0,0 +1,66 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.with +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance + +@ExperimentalAnimationApi +@Composable +fun Scaffold( + topIconButtonId: Int, + onTopIconButtonClick: () -> Unit, + tabIndex: Int, + onTabChanged: (Int) -> Unit, + tabColumnContent: @Composable ColumnScope.(@Composable (Int, String, Int) -> Unit) -> Unit, + modifier: Modifier = Modifier, + content: @Composable AnimatedVisibilityScope.(Int) -> Unit +) { + val (colorPalette) = LocalAppearance.current + + Row( + modifier = modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + NavigationRail( + topIconButtonId = topIconButtonId, + onTopIconButtonClick = onTopIconButtonClick, + tabIndex = tabIndex, + onTabIndexChanged = onTabChanged, + content = tabColumnContent + ) + + AnimatedContent( + targetState = tabIndex, + transitionSpec = { + val slideDirection = when (targetState > initialState) { + true -> AnimatedContentScope.SlideDirection.Up + false -> AnimatedContentScope.SlideDirection.Down + } + + val animationSpec = spring( + dampingRatio = 0.9f, + stiffness = Spring.StiffnessLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ) + + slideIntoContainer(slideDirection, animationSpec) with + slideOutOfContainer(slideDirection, animationSpec) + }, + content = content + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryButton.kt new file mode 100644 index 0000000..894b7e7 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryButton.kt @@ -0,0 +1,44 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance + +@Composable +fun SecondaryButton( + onClick: () -> Unit, + @DrawableRes iconId: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val (colorPalette) = LocalAppearance.current + + Box( + modifier = modifier + .clip(CircleShape) + .clickable(enabled = enabled, onClick = onClick) + .background(colorPalette.background2) + .size(48.dp) + ) { + Image( + painter = painterResource(iconId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .align(Alignment.Center) + .size(18.dp) + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryTextButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryTextButton.kt new file mode 100644 index 0000000..1a2727d --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryTextButton.kt @@ -0,0 +1,35 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.medium + +@Composable +fun SecondaryTextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + alternative: Boolean = false +) { + val (colorPalette, typography) = LocalAppearance.current + + BasicText( + text = text, + style = typography.xxs.medium, + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = enabled, onClick = onClick) + .background(if (alternative) colorPalette.background0 else colorPalette.background2) + .padding(all = 8.dp) + .padding(horizontal = 8.dp) + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextCard.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextCard.kt deleted file mode 100644 index d87311a..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextCard.kt +++ /dev/null @@ -1,113 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.align -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold - -@Composable -fun TextCard( - modifier: Modifier = Modifier, - @DrawableRes icon: Int? = null, - iconColor: ColorFilter? = null, - onClick: (() -> Unit)? = null, - content: @Composable TextCardScope.() -> Unit, -) { - val (colorPalette) = LocalAppearance.current - - Column( - modifier = modifier - .padding(horizontal = 16.dp, vertical = 16.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = true), - enabled = onClick != null, - onClick = onClick ?: {} - ) - .background(colorPalette.background1) - .padding(horizontal = 16.dp, vertical = 16.dp) - ) { - icon?.let { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = iconColor ?: ColorFilter.tint(Color.Red), - modifier = Modifier - .padding(bottom = 16.dp) - .size(24.dp) - ) - } - - (icon?.let { IconTextCardScopeImpl } ?: TextCardScopeImpl).content() - } -} - -interface TextCardScope { - @Composable - fun Title(text: String) - - @Composable - fun Text(text: String) -} - -private object TextCardScopeImpl : TextCardScope { - @Composable - override fun Title(text: String) { - val (_, typography) = LocalAppearance.current - BasicText( - text = text, - style = typography.xxs.semiBold, - ) - } - - @Composable - override fun Text(text: String) { - val (_, typography) = LocalAppearance.current - BasicText( - text = text, - style = typography.xxs.secondary.align(TextAlign.Justify), - ) - } -} - -private object IconTextCardScopeImpl : TextCardScope { - @Composable - override fun Title(text: String) { - val (_, typography) = LocalAppearance.current - BasicText( - text = text, - style = typography.xxs.semiBold, - modifier = Modifier - .padding(horizontal = 16.dp) - ) - } - - @Composable - override fun Text(text: String) { - val (_, typography) = LocalAppearance.current - BasicText( - text = text, - style = typography.xxs.secondary, - modifier = Modifier - .padding(horizontal = 16.dp) - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt new file mode 100644 index 0000000..911540a --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt @@ -0,0 +1,155 @@ +package it.vfsfitvnm.vimusic.ui.items + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import it.vfsfitvnm.vimusic.models.Album +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.shimmer +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.vimusic.utils.thumbnail +import it.vfsfitvnm.youtubemusic.Innertube + +@Composable +fun AlbumItem( + album: Album, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) { + AlbumItem( + thumbnailUrl = album.thumbnailUrl, + title = album.title, + authors = album.authorsText, + year = album.year, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + alternative = alternative, + modifier = modifier + ) +} + +@Composable +fun AlbumItem( + album: Innertube.AlbumItem, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) { + AlbumItem( + thumbnailUrl = album.thumbnail?.url, + title = album.info?.name, + authors = album.authors?.joinToString("") { it.name ?: "" }, + year = album.year, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + alternative = alternative, + modifier = modifier + ) +} + +@Composable +fun AlbumItem( + thumbnailUrl: String?, + title: String?, + authors: String?, + year: String?, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) { + val (_, typography, thumbnailShape) = LocalAppearance.current + + ItemContainer( + alternative = alternative, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier + ) { + AsyncImage( + model = thumbnailUrl?.thumbnail(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(thumbnailSizeDp) + ) + + ItemInfoContainer { + BasicText( + text = title ?: "", + style = typography.xs.semiBold, + maxLines = if (alternative) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + + if (!alternative) { + authors?.let { + BasicText( + text = authors, + style = typography.xs.semiBold.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + BasicText( + text = year ?: "", + style = typography.xxs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 4.dp) + ) + } + } +} + +@Composable +fun AlbumItemPlaceholder( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + ItemContainer( + alternative = alternative, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSizeDp) + ) + + ItemInfoContainer { + TextPlaceholder() + + if (!alternative) { + TextPlaceholder() + } + + TextPlaceholder( + modifier = Modifier + .padding(top = 4.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt new file mode 100644 index 0000000..64c2892 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt @@ -0,0 +1,145 @@ +package it.vfsfitvnm.vimusic.ui.items + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import it.vfsfitvnm.vimusic.models.Artist +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.shimmer +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.vimusic.utils.thumbnail +import it.vfsfitvnm.youtubemusic.Innertube + +@Composable +fun ArtistItem( + artist: Artist, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + ArtistItem( + thumbnailUrl = artist.thumbnailUrl, + name = artist.name, + subscribersCount = null, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier, + alternative = alternative + ) +} + +@Composable +fun ArtistItem( + artist: Innertube.ArtistItem, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + ArtistItem( + thumbnailUrl = artist.thumbnail?.url, + name = artist.info?.name, + subscribersCount = artist.subscribersCountText, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier, + alternative = alternative + ) +} + +@Composable +fun ArtistItem( + thumbnailUrl: String?, + name: String?, + subscribersCount: String?, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + val (_, typography) = LocalAppearance.current + + ItemContainer( + alternative = alternative, + thumbnailSizeDp = thumbnailSizeDp, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + AsyncImage( + model = thumbnailUrl?.thumbnail(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .requiredSize(thumbnailSizeDp) + ) + + ItemInfoContainer( + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, + ) { + BasicText( + text = name ?: "", + style = typography.xs.semiBold, + maxLines = if (alternative) 1 else 2, + overflow = TextOverflow.Ellipsis + ) + + subscribersCount?.let { + BasicText( + text = subscribersCount, + style = typography.xxs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 4.dp) + ) + } + } + } +} + +@Composable +fun ArtistItemPlaceholder( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + val (colorPalette) = LocalAppearance.current + + ItemContainer( + alternative = alternative, + thumbnailSizeDp = thumbnailSizeDp, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = CircleShape) + .size(thumbnailSizeDp) + ) + + ItemInfoContainer( + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, + ) { + TextPlaceholder() + TextPlaceholder( + modifier = Modifier + .padding(top = 4.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ItemContainer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ItemContainer.kt new file mode 100644 index 0000000..fe16910 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ItemContainer.kt @@ -0,0 +1,68 @@ +package it.vfsfitvnm.vimusic.ui.items + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.ui.styling.Dimensions + +@Composable +inline fun ItemContainer( + alternative: Boolean, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + content: @Composable (centeredModifier: Modifier) -> Unit +) { + if (alternative) { + Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .width(thumbnailSizeDp) + ) { + content( + centeredModifier = Modifier + .align(Alignment.CenterHorizontally) + ) + } + } else { + Row( + verticalAlignment = verticalAlignment, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .fillMaxWidth() + ) { + content( + centeredModifier = Modifier + .align(Alignment.CenterVertically) + ) + } + } +} + +@Composable +inline fun ItemInfoContainer( + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit +) { + Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + content = content + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/PlaylistItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/PlaylistItem.kt new file mode 100644 index 0000000..91ad1a5 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/PlaylistItem.kt @@ -0,0 +1,274 @@ +package it.vfsfitvnm.vimusic.ui.items + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.models.PlaylistPreview +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.onOverlay +import it.vfsfitvnm.vimusic.ui.styling.overlay +import it.vfsfitvnm.vimusic.ui.styling.shimmer +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.vimusic.utils.thumbnail +import it.vfsfitvnm.youtubemusic.Innertube +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +@Composable +fun PlaylistItem( + @DrawableRes icon: Int, + colorTint: Color, + name: String?, + songCount: Int?, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + PlaylistItem( + thumbnailContent = { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(colorTint), + modifier = Modifier + .align(Alignment.Center) + .size(24.dp) + ) + }, + songCount = songCount, + name = name, + channelName = null, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier, + alternative = alternative + ) +} + +@Composable +fun PlaylistItem( + playlist: PlaylistPreview, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + val thumbnails by remember { + Database.playlistThumbnailUrls(playlist.playlist.id).distinctUntilChanged().map { + it.map { url -> + url.thumbnail(thumbnailSizePx / 2) + } + } + }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + + PlaylistItem( + thumbnailContent = { + if (thumbnails.toSet().size == 1) { + AsyncImage( + model = thumbnails.first().thumbnail(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = it + ) + } else { + Box( + modifier = it + .fillMaxSize() + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).forEachIndexed { index, alignment -> + AsyncImage( + model = thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(thumbnailSizeDp / 2) + ) + } + } + } + }, + songCount = playlist.songCount, + name = playlist.playlist.name, + channelName = null, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier, + alternative = alternative + ) +} + +@Composable +fun PlaylistItem( + playlist: Innertube.PlaylistItem, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + PlaylistItem( + thumbnailUrl = playlist.thumbnail?.url, + songCount = playlist.songCount, + name = playlist.info?.name, + channelName = playlist.channel?.name, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier, + alternative = alternative + ) +} + +@Composable +fun PlaylistItem( + thumbnailUrl: String?, + songCount: Int?, + name: String?, + channelName: String?, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + PlaylistItem( + thumbnailContent = { + AsyncImage( + model = thumbnailUrl?.thumbnail(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = it + ) + }, + songCount = songCount, + name = name, + channelName = channelName, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier, + alternative = alternative, + ) +} + +@Composable +fun PlaylistItem( + thumbnailContent: @Composable BoxScope.(modifier: Modifier) -> Unit, + songCount: Int?, + name: String?, + channelName: String?, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + + ItemContainer( + alternative = alternative, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier + ) { centeredModifier -> + Box( + modifier = centeredModifier + .clip(thumbnailShape) + .background(color = colorPalette.background1) + .requiredSize(thumbnailSizeDp) + ) { + thumbnailContent( + modifier = Modifier + .fillMaxSize() + ) + + songCount?.let { + BasicText( + text = "$songCount", + style = typography.xxs.medium.color(colorPalette.onOverlay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(all = 4.dp) + .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + .align(Alignment.BottomEnd) + ) + } + } + + ItemInfoContainer( + horizontalAlignment = if (alternative && channelName == null) Alignment.CenterHorizontally else Alignment.Start, + ) { + BasicText( + text = name ?: "", + style = typography.xs.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + channelName?.let { + BasicText( + text = channelName, + style = typography.xs.semiBold.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +fun PlaylistItemPlaceholder( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false, +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + ItemContainer( + alternative = alternative, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSizeDp) + ) + + ItemInfoContainer( + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, + ) { + TextPlaceholder() + TextPlaceholder() + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt new file mode 100644 index 0000000..728af35 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt @@ -0,0 +1,218 @@ +package it.vfsfitvnm.vimusic.ui.items + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import coil.compose.AsyncImage +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.shimmer +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.vimusic.utils.thumbnail +import it.vfsfitvnm.youtubemusic.Innertube + +@Composable +fun SongItem( + song: Innertube.SongItem, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier +) { + SongItem( + thumbnailUrl = song.thumbnail?.size(thumbnailSizePx), + title = song.info?.name, + authors = song.authors?.joinToString("") { it.name ?: "" }, + duration = song.durationText, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier, + ) +} + +@Composable +fun SongItem( + song: MediaItem, + thumbnailSizeDp: Dp, + thumbnailSizePx: Int, + modifier: Modifier = Modifier, + onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null +) { + SongItem( + thumbnailUrl = song.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx)?.toString(), + title = song.mediaMetadata.title.toString(), + authors = song.mediaMetadata.artist.toString(), + duration = song.mediaMetadata.extras?.getString("durationText"), + thumbnailSizeDp = thumbnailSizeDp, + onThumbnailContent = onThumbnailContent, + trailingContent = trailingContent, + modifier = modifier, + ) +} + +@Composable +fun SongItem( + song: DetailedSong, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null +) { + SongItem( + thumbnailUrl = song.thumbnailUrl?.thumbnail(thumbnailSizePx), + title = song.title, + authors = song.artistsText, + duration = song.durationText, + thumbnailSizeDp = thumbnailSizeDp, + onThumbnailContent = onThumbnailContent, + trailingContent = trailingContent, + modifier = modifier, + ) +} + +@Composable +fun SongItem( + thumbnailUrl: String?, + title: String?, + authors: String?, + duration: String?, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null +) { + SongItem( + title = title, + authors = authors, + duration = duration, + thumbnailSizeDp = thumbnailSizeDp, + thumbnailContent = { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(LocalAppearance.current.thumbnailShape) + .fillMaxSize() + ) + + onThumbnailContent?.invoke(this) + }, + modifier = modifier, + trailingContent = trailingContent + ) +} + +@Composable +fun SongItem( + thumbnailContent: @Composable BoxScope.() -> Unit, + title: String?, + authors: String?, + duration: String?, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, + trailingContent: @Composable (() -> Unit)? = null, +) { + val (_, typography) = LocalAppearance.current + + ItemContainer( + alternative = false, + thumbnailSizeDp = thumbnailSizeDp, + modifier = modifier + ) { + Box( + modifier = Modifier + .size(thumbnailSizeDp) + ) { + thumbnailContent() + } + + ItemInfoContainer { + trailingContent?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + BasicText( + text = title ?: "", + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + ) + + it() + } + } ?: BasicText( + text = title ?: "", + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + + Row(verticalAlignment = Alignment.CenterVertically) { + BasicText( + text = authors ?: "", + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Clip, + modifier = Modifier + .weight(1f) + ) + + duration?.let { + BasicText( + text = duration, + style = typography.xxs.secondary.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 4.dp) + ) + } + } + } + } +} + +@Composable +fun SongItemPlaceholder( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + ItemContainer( + alternative = false, + thumbnailSizeDp =thumbnailSizeDp, + modifier = modifier + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSizeDp) + ) + + ItemInfoContainer { + TextPlaceholder() + TextPlaceholder() + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt new file mode 100644 index 0000000..06aa30b --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt @@ -0,0 +1,149 @@ +package it.vfsfitvnm.vimusic.ui.items + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.onOverlay +import it.vfsfitvnm.vimusic.ui.styling.overlay +import it.vfsfitvnm.vimusic.ui.styling.shimmer +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.youtubemusic.Innertube + +@Composable +fun VideoItem( + video: Innertube.VideoItem, + thumbnailHeightDp: Dp, + thumbnailWidthDp: Dp, + modifier: Modifier = Modifier +) { + VideoItem( + thumbnailUrl = video.thumbnail?.url, + duration = video.durationText, + title = video.info?.name, + uploader = video.authors?.joinToString("") { it.name ?: "" }, + views = video.viewsText, + thumbnailHeightDp = thumbnailHeightDp, + thumbnailWidthDp = thumbnailWidthDp, + modifier = modifier + ) +} + +@Composable +fun VideoItem( + thumbnailUrl: String?, + duration: String?, + title: String?, + uploader: String?, + views: String?, + thumbnailHeightDp: Dp, + thumbnailWidthDp: Dp, + modifier: Modifier = Modifier +) { + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + + ItemContainer( + alternative = false, + thumbnailSizeDp = 0.dp, + modifier = modifier + ) { + Box { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(width = thumbnailWidthDp, height = thumbnailHeightDp) + ) + + duration?.let { + BasicText( + text = duration, + style = typography.xxs.medium.color(colorPalette.onOverlay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(all = 4.dp) + .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + .align(Alignment.BottomEnd) + ) + } + } + + ItemInfoContainer { + BasicText( + text = title ?: "", + style = typography.xs.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + BasicText( + text = uploader ?: "", + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + views?.let { + BasicText( + text = views, + style = typography.xxs.medium.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 4.dp) + ) + } + } + } +} + +@Composable +fun VideoItemPlaceholder( + thumbnailHeightDp: Dp, + thumbnailWidthDp: Dp, + modifier: Modifier = Modifier +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + ItemContainer( + alternative = false, + thumbnailSizeDp = 0.dp, + modifier = modifier + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(width = thumbnailWidthDp, height = thumbnailHeightDp) + ) + + ItemInfoContainer { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder( + modifier = Modifier + .padding(top = 8.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt deleted file mode 100644 index b36df80..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt +++ /dev/null @@ -1,421 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import android.content.Intent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import coil.compose.AsyncImage -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.models.Album -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.SongAlbumMap -import it.vfsfitvnm.vimusic.models.SongPlaylistMap -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError -import it.vfsfitvnm.vimusic.ui.components.themed.Menu -import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.bold -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.vimusic.utils.toMediaItem -import it.vfsfitvnm.youtubemusic.YouTube -import java.text.DateFormat -import java.util.Date -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map - -@ExperimentalAnimationApi -@Composable -fun AlbumScreen(browseId: String) { - val lazyListState = rememberLazyListState() - - val albumResult by remember(browseId) { - Database.album(browseId).map { album -> - album - ?.takeIf { album.timestamp != null } - ?.let(Result.Companion::success) - ?: YouTube.album(browseId)?.map { youtubeAlbum -> - Database.upsert( - Album( - id = browseId, - title = youtubeAlbum.title, - thumbnailUrl = youtubeAlbum.thumbnail?.url, - year = youtubeAlbum.year, - authorsText = youtubeAlbum.authors?.joinToString("") { it.name }, - shareUrl = youtubeAlbum.url, - timestamp = System.currentTimeMillis() - ), - youtubeAlbum.items?.mapIndexedNotNull { position, albumItem -> - albumItem.toMediaItem(browseId, youtubeAlbum)?.let { mediaItem -> - Database.insert(mediaItem) - SongAlbumMap( - songId = mediaItem.mediaId, - albumId = browseId, - position = position - ) - } - } ?: emptyList() - ) - - null - } - }.distinctUntilChanged() - }.collectAsState(initial = null, context = Dispatchers.IO) - - val songs by remember(browseId) { - Database.albumSongs(browseId) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val context = LocalContext.current - val binder = LocalPlayerServiceBinder.current - - val (colorPalette, typography) = LocalAppearance.current - val menuState = LocalMenuState.current - - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - 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(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - } - } - - item { - albumResult?.getOrNull()?.let { album -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Max) - .padding(vertical = 8.dp, horizontal = 16.dp) - .padding(bottom = 8.dp) - ) { - AsyncImage( - model = album.thumbnailUrl?.thumbnail(Dimensions.thumbnails.album.px), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(Dimensions.thumbnails.album) - ) - - Column( - verticalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = album.title ?: "Unknown", - style = typography.m.semiBold - ) - - BasicText( - text = album.authorsText ?: "", - style = typography.xs.secondary.semiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - album.year?.let { year -> - BasicText( - text = year, - style = typography.xs.secondary, - maxLines = 1, - modifier = Modifier - .padding(top = 8.dp) - ) - } - } - } - } ?: albumResult?.exceptionOrNull()?.let { throwable -> - LoadingOrError(errorMessage = throwable.javaClass.canonicalName) - } ?: LoadingOrError() - } - - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier - .fillMaxWidth() - .zIndex(1f) - .padding(horizontal = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(enabled = songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs - .shuffled() - .map(DetailedSong::asMediaItem) - ) - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - menuState.display { - Menu { - MenuEntry( - icon = R.drawable.enqueue, - text = "Enqueue", - onClick = { - menuState.hide() - binder?.player?.enqueue( - songs.map(DetailedSong::asMediaItem) - ) - } - ) - - MenuEntry( - icon = R.drawable.playlist, - text = "Import as playlist", - onClick = { - menuState.hide() - - albumResult - ?.getOrNull() - ?.let { album -> - query { - val playlistId = - Database.insert( - Playlist( - name = album.title - ?: "Unknown" - ) - ) - - songs.forEachIndexed { index, song -> - Database.insert( - SongPlaylistMap( - songId = song.id, - playlistId = playlistId, - position = index - ) - ) - } - } - } - } - ) - - MenuEntry( - icon = R.drawable.share_social, - text = "Share", - onClick = { - menuState.hide() - - albumResult?.getOrNull()?.shareUrl?.let { url -> - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - } - - context.startActivity( - Intent.createChooser( - sendIntent, - null - ) - ) - } - } - ) - - MenuEntry( - icon = R.drawable.download, - text = "Refetch", - secondaryText = albumResult?.getOrNull()?.timestamp?.let { timestamp -> - "Last updated on ${ - DateFormat - .getDateTimeInstance() - .format(Date(timestamp)) - }" - }, - isEnabled = albumResult?.getOrNull() != null, - onClick = { - menuState.hide() - - query { - albumResult - ?.getOrNull() - ?.let(Database::delete) - } - } - ) - } - } - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - } - } - - itemsIndexed( - items = songs, - key = { _, song -> song.id }, - contentType = { _, song -> song } - ) { index, song -> - SongItem( - title = song.title, - authors = song.artistsText ?: albumResult?.getOrNull()?.authorsText, - durationText = song.durationText, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(DetailedSong::asMediaItem), - index - ) - }, - startContent = { - BasicText( - text = "${index + 1}", - style = typography.xs.secondary.bold.center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .width(36.dp) - ) - }, - menuContent = { - NonQueuedMediaItemMenu( - mediaItem = song.asMediaItem, - onDismiss = menuState::hide, - ) - } - ) - } - } - } - } -} - -@Composable -private fun LoadingOrError( - errorMessage: String? = null, - onRetry: (() -> Unit)? = null -) { - val (colorPalette) = LocalAppearance.current - - LoadingOrError( - errorMessage = errorMessage, - onRetry = onRetry - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .height(IntrinsicSize.Max) - .padding(vertical = 8.dp, horizontal = 16.dp) - .padding(bottom = 8.dp) - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = ThumbnailRoundness.shape) - .size(Dimensions.thumbnails.album) - ) - - Column( - verticalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxHeight() - ) { - Column { - TextPlaceholder() - - TextPlaceholder( - modifier = Modifier - .alpha(0.7f) - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt deleted file mode 100644 index 26638b7..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt +++ /dev/null @@ -1,379 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.center -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import coil.compose.AsyncImage -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Artist -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.youtubemusic.YouTube -import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking - -@ExperimentalAnimationApi -@Composable -fun ArtistScreen(browseId: String) { - val lazyListState = rememberLazyListState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val binder = LocalPlayerServiceBinder.current - - val (colorPalette, typography) = LocalAppearance.current - - val artistResult by remember(browseId) { - Database.artist(browseId).map { artist -> - artist - ?.takeIf { artist.timestamp != null } - ?.let(Result.Companion::success) - ?: fetchArtist(browseId) - }.distinctUntilChanged() - }.collectAsState(initial = null, context = Dispatchers.IO) - - val songThumbnailSizePx = Dimensions.thumbnails.song.px - - val songs by remember(browseId) { - Database.artistSongs(browseId) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwarePaddingValues.current, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - 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(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - } - } - - item { - artistResult?.getOrNull()?.let { artist -> - AsyncImage( - model = artist.thumbnailUrl?.thumbnail(Dimensions.thumbnails.artist.px), - contentDescription = null, - modifier = Modifier - .clip(CircleShape) - .size(Dimensions.thumbnails.artist) - ) - - BasicText( - text = artist.name, - style = typography.l.semiBold, - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(32.dp), - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - binder?.playRadio( - NavigationEndpoint.Endpoint.Watch( - videoId = artist.shuffleVideoId, - playlistId = artist.shufflePlaylistId - ) - ) - - query { - runBlocking { - fetchArtist(browseId) - } - } - } - .padding(all = 8.dp) - .size(20.dp) - ) - - Image( - painter = painterResource(R.drawable.radio), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - binder?.playRadio( - NavigationEndpoint.Endpoint.Watch( - videoId = artist.radioVideoId - ?: artist.shuffleVideoId, - playlistId = artist.radioPlaylistId - ) - ) - - query { - runBlocking { - fetchArtist(browseId) - } - } - } - .padding(all = 8.dp) - .size(20.dp) - ) - } - } ?: artistResult?.exceptionOrNull()?.let { throwable -> - LoadingOrError( - errorMessage = throwable.javaClass.canonicalName, - onRetry = { - query { - runBlocking { - Database.artist(browseId).first()?.let(Database::update) - } - } - } - ) - } ?: LoadingOrError() - } - - item("songs") { - if (songs.isEmpty()) return@item - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .background(colorPalette.background0) - .zIndex(1f) - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(top = 32.dp) - ) { - BasicText( - text = "Local tracks", - style = typography.m.semiBold, - modifier = Modifier - .padding(horizontal = 8.dp) - ) - - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(enabled = songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs - .shuffled() - .map(DetailedSong::asMediaItem) - ) - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - } - } - - itemsIndexed( - items = songs, - key = { _, song -> song.id }, - contentType = { _, song -> song }, - ) { index, song -> - SongItem( - song = song, - thumbnailSize = songThumbnailSizePx, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(DetailedSong::asMediaItem), - index - ) - }, - menuContent = { - InHistoryMediaItemMenu(song = song) - } - ) - } - - artistResult?.getOrNull()?.info?.let { description -> - item { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .background(colorPalette.background0) - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(top = 32.dp) - ) { - BasicText( - text = "Information", - style = typography.m.semiBold, - modifier = Modifier - .padding(horizontal = 8.dp) - ) - - Row( - modifier = Modifier - .height(IntrinsicSize.Max) - .padding(all = 8.dp) - .fillMaxWidth() - ) { - Canvas( - modifier = Modifier - .fillMaxHeight() - .width(48.dp) - ) { - drawLine( - color = colorPalette.background2, - start = size.center.copy(y = 0f), - end = size.center.copy(y = size.height), - strokeWidth = 2.dp.toPx() - ) - - drawCircle( - color = colorPalette.background2, - center = size.center.copy(y = size.height), - radius = 4.dp.toPx() - ) - } - - BasicText( - text = description, - style = typography.xxs.secondary.medium.copy( - lineHeight = 24.sp, - textAlign = TextAlign.Justify - ), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - } - } - } - } - } - } - } -} - -@Composable -private fun LoadingOrError( - errorMessage: String? = null, - onRetry: (() -> Unit)? = null -) { - val (colorPalette) = LocalAppearance.current - - LoadingOrError( - errorMessage = errorMessage, - onRetry = onRetry, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = CircleShape) - .size(Dimensions.thumbnails.artist) - ) - - TextPlaceholder( - modifier = Modifier - .alpha(0.9f) - .padding(vertical = 8.dp, horizontal = 16.dp) - ) - - repeat(3) { - TextPlaceholder( - modifier = Modifier - .alpha(0.8f) - .padding(horizontal = 16.dp) - ) - } - } -} - -private suspend fun fetchArtist(browseId: String): Result? { - return YouTube.artist(browseId) - ?.map { youtubeArtist -> - Artist( - id = browseId, - name = youtubeArtist.name, - thumbnailUrl = youtubeArtist.thumbnail?.url, - info = youtubeArtist.description, - shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId, - shufflePlaylistId = youtubeArtist.shuffleEndpoint?.playlistId, - radioVideoId = youtubeArtist.radioEndpoint?.videoId, - radioPlaylistId = youtubeArtist.radioEndpoint?.playlistId, - timestamp = System.currentTimeMillis() - ).also(Database::upsert) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/BuiltInPlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/BuiltInPlaylistScreen.kt deleted file mode 100644 index 3493473..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/BuiltInPlaylistScreen.kt +++ /dev/null @@ -1,207 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.InFavoritesMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.Menu -import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.map - -@ExperimentalAnimationApi -@Composable -fun BuiltInPlaylistScreen(builtInPlaylist: BuiltInPlaylist) { - val lazyListState = rememberLazyListState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val menuState = LocalMenuState.current - - val binder = LocalPlayerServiceBinder.current - val (colorPalette, typography) = LocalAppearance.current - - val thumbnailSize = Dimensions.thumbnails.song.px - - val songs by remember(binder?.cache, builtInPlaylist) { - when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> Database.favorites() - BuiltInPlaylist.Offline -> Database.songsWithContentLength().map { songs -> - songs.filter { song -> - song.contentLength?.let { - binder?.cache?.isCached(song.id, 0, song.contentLength) - } ?: false - } - } - } - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - 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(vertical = 8.dp, horizontal = 16.dp) - .size(24.dp) - ) - } - } - - item { - Column( - modifier = Modifier - .padding(top = 16.dp, bottom = 8.dp) - .padding(horizontal = 16.dp) - ) { - BasicText( - text = when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> "Favorites" - BuiltInPlaylist.Offline -> "Offline" - }, - style = typography.m.semiBold - ) - - BasicText( - text = "${songs.size} songs", - style = typography.xxs.semiBold.secondary - ) - } - } - - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier - .fillMaxWidth() - .zIndex(1f) - .padding(horizontal = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(enabled = songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs - .shuffled() - .map(DetailedSong::asMediaItem) - ) - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - menuState.display { - Menu { - MenuEntry( - icon = R.drawable.enqueue, - text = "Enqueue", - isEnabled = songs.isNotEmpty(), - onClick = { - menuState.hide() - binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem)) - } - ) - } - } - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - } - } - - itemsIndexed( - items = songs, - key = { _, song -> song.id }, - contentType = { _, song -> song }, - ) { index, song -> - SongItem( - song = song, - thumbnailSize = thumbnailSize, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(DetailedSong::asMediaItem), - index - ) - }, - menuContent = { - when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> InFavoritesMediaItemMenu(song = song) - BuiltInPlaylist.Offline -> InHistoryMediaItemMenu(song = song) - } - } - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt deleted file mode 100644 index 43c9bff..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt +++ /dev/null @@ -1,563 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import android.net.Uri -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist -import it.vfsfitvnm.vimusic.enums.PlaylistSortBy -import it.vfsfitvnm.vimusic.enums.SongSortBy -import it.vfsfitvnm.vimusic.enums.SortOrder -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.SearchQuery -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.badge -import it.vfsfitvnm.vimusic.ui.components.themed.DropDownSection -import it.vfsfitvnm.vimusic.ui.components.themed.DropDownSectionSpacer -import it.vfsfitvnm.vimusic.ui.components.themed.DropDownTextItem -import it.vfsfitvnm.vimusic.ui.components.themed.DropdownMenu -import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.views.BuiltInPlaylistItem -import it.vfsfitvnm.vimusic.ui.views.PlaylistPreviewItem -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.isFirstLaunchKey -import it.vfsfitvnm.vimusic.utils.playlistGridExpandedKey -import it.vfsfitvnm.vimusic.utils.playlistSortByKey -import it.vfsfitvnm.vimusic.utils.playlistSortOrderKey -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.songSortByKey -import it.vfsfitvnm.vimusic.utils.songSortOrderKey -import kotlinx.coroutines.Dispatchers - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun HomeScreen() { - val (colorPalette, typography) = LocalAppearance.current - - val lazyListState = rememberLazyListState() - val lazyHorizontalGridState = rememberLazyGridState() - - var playlistSortBy by rememberPreference(playlistSortByKey, PlaylistSortBy.DateAdded) - var playlistSortOrder by rememberPreference(playlistSortOrderKey, SortOrder.Descending) - var playlistGridExpanded by rememberPreference(playlistGridExpandedKey, false) - - val playlistPreviews by remember(playlistSortBy, playlistSortOrder) { - Database.playlistPreviews(playlistSortBy, playlistSortOrder) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - var songSortBy by rememberPreference(songSortByKey, SongSortBy.DateAdded) - var songSortOrder by rememberPreference(songSortOrderKey, SortOrder.Descending) - - val songCollection by remember(songSortBy, songSortOrder) { - Database.songs(songSortBy, songSortOrder) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - RouteHandler(listenToGlobalEmitter = true) { - settingsRoute { - SettingsScreen() - } - - localPlaylistRoute { playlistId -> - LocalPlaylistScreen( - playlistId = playlistId ?: error("playlistId cannot be null") - ) - } - - builtInPlaylistRoute { builtInPlaylist -> - BuiltInPlaylistScreen( - builtInPlaylist = builtInPlaylist - ) - } - - searchResultRoute { query -> - SearchResultScreen( - query = query, - onSearchAgain = { - searchRoute(query) - } - ) - } - - searchRoute { initialTextInput -> - SearchScreen( - initialTextInput = initialTextInput, - onSearch = { query -> - searchResultRoute(query) - - query { - Database.insert(SearchQuery(query = query)) - } - }, - onUri = { uri -> - intentUriRoute(uri) - } - ) - } - - albumRoute { browseId -> - AlbumScreen(browseId = browseId ?: error("browseId cannot be null")) - } - - artistRoute { browseId -> - ArtistScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } - - intentUriRoute { uri -> - IntentUriScreen( - uri = uri ?: Uri.EMPTY - ) - } - - host { - // This somehow prevents items to not be displayed sometimes... - @Suppress("UNUSED_EXPRESSION") playlistPreviews - @Suppress("UNUSED_EXPRESSION") songCollection - - val binder = LocalPlayerServiceBinder.current - - val isFirstLaunch by rememberPreference(isFirstLaunchKey, true) - - val thumbnailSize = Dimensions.thumbnails.song.px - - var isCreatingANewPlaylist by rememberSaveable { - mutableStateOf(false) - } - - if (isCreatingANewPlaylist) { - TextFieldDialog( - hintText = "Enter the playlist name", - onDismiss = { - isCreatingANewPlaylist = false - }, - onDone = { text -> - query { - Database.insert(Playlist(name = text)) - } - } - ) - } - - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item("topAppBar") { - TopAppBar( - modifier = Modifier - .height(52.dp) - ) { - Image( - painter = painterResource(R.drawable.equalizer), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { settingsRoute() } - .padding(horizontal = 16.dp, vertical = 8.dp) - .badge(color = colorPalette.red, isDisplayed = isFirstLaunch) - .size(24.dp) - ) - - Image( - painter = painterResource(R.drawable.search), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { searchRoute("") } - .padding(horizontal = 16.dp, vertical = 8.dp) - .size(24.dp) - ) - } - } - - item("playlistsHeader") { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .zIndex(1f) - .padding(horizontal = 8.dp) - .padding(top = 16.dp) - ) { - BasicText( - text = "Your playlists", - style = typography.m.semiBold, - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp) - ) - - Image( - painter = painterResource(R.drawable.add), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { isCreatingANewPlaylist = true } - .padding(all = 8.dp) - .size(20.dp) - ) - - Box { - var isSortMenuDisplayed by remember { - mutableStateOf(false) - } - - Image( - painter = painterResource(R.drawable.sort), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { isSortMenuDisplayed = true } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - DropdownMenu( - isDisplayed = isSortMenuDisplayed, - onDismissRequest = { isSortMenuDisplayed = false } - ) { - DropDownSection { - DropDownTextItem( - text = "NAME", - isSelected = playlistSortBy == PlaylistSortBy.Name, - onClick = { - isSortMenuDisplayed = false - playlistSortBy = PlaylistSortBy.Name - } - ) - - DropDownTextItem( - text = "DATE ADDED", - isSelected = playlistSortBy == PlaylistSortBy.DateAdded, - onClick = { - isSortMenuDisplayed = false - playlistSortBy = PlaylistSortBy.DateAdded - } - ) - - DropDownTextItem( - text = "SONG COUNT", - isSelected = playlistSortBy == PlaylistSortBy.SongCount, - onClick = { - isSortMenuDisplayed = false - playlistSortBy = PlaylistSortBy.SongCount - } - ) - } - - DropDownSectionSpacer() - - DropDownSection { - DropDownTextItem( - text = when (playlistSortOrder) { - SortOrder.Ascending -> "ASCENDING" - SortOrder.Descending -> "DESCENDING" - }, - onClick = { - isSortMenuDisplayed = false - playlistSortOrder = !playlistSortOrder - } - ) - } - DropDownSectionSpacer() - - DropDownSection { - DropDownTextItem( - text = when (playlistGridExpanded) { - true -> "COLLAPSE" - false -> "EXPAND" - }, - onClick = { - isSortMenuDisplayed = false - playlistGridExpanded = !playlistGridExpanded - } - ) - } - } - } - } - } - - item("playlists") { - LazyHorizontalGrid( - state = lazyHorizontalGridState, - rows = GridCells.Fixed(if (playlistGridExpanded) 3 else 1), - contentPadding = PaddingValues(horizontal = 16.dp), - modifier = Modifier - .animateContentSize() - .fillMaxWidth() - .height(124.dp * (if (playlistGridExpanded) 3 else 1)) - ) { - item(key = "favorites") { - BuiltInPlaylistItem( - icon = R.drawable.heart, - colorTint = colorPalette.red, - name = "Favorites", - modifier = Modifier - .animateItemPlacement() - .padding(all = 8.dp) - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { builtInPlaylistRoute(BuiltInPlaylist.Favorites) } - ) - ) - } - - item(key = "offline") { - BuiltInPlaylistItem( - icon = R.drawable.airplane, - colorTint = colorPalette.blue, - name = "Offline", - modifier = Modifier - .animateItemPlacement() - .padding(all = 8.dp) - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { builtInPlaylistRoute(BuiltInPlaylist.Offline) } - ) - ) - } - - items( - items = playlistPreviews, - key = { it.playlist.id }, - contentType = { it } - ) { playlistPreview -> - PlaylistPreviewItem( - playlistPreview = playlistPreview, - modifier = Modifier - .animateItemPlacement() - .padding(all = 8.dp) - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { localPlaylistRoute(playlistPreview.playlist.id) } - ) - ) - } - } - } - - item("songs") { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .background(colorPalette.background0) - .zIndex(1f) - .padding(horizontal = 8.dp) - .padding(top = 32.dp) - ) { - BasicText( - text = "Songs", - style = typography.m.semiBold, - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp) - ) - - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(enabled = songCollection.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songCollection - .shuffled() - .map(DetailedSong::asMediaItem) - ) - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - Box { - var isSortMenuDisplayed by remember { - mutableStateOf(false) - } - - Image( - painter = painterResource(R.drawable.sort), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - isSortMenuDisplayed = true - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - DropdownMenu( - isDisplayed = isSortMenuDisplayed, - onDismissRequest = { - isSortMenuDisplayed = false - } - ) { - DropDownSection { - DropDownTextItem( - text = "PLAY TIME", - isSelected = songSortBy == SongSortBy.PlayTime, - onClick = { - isSortMenuDisplayed = false - songSortBy = SongSortBy.PlayTime - } - ) - - DropDownTextItem( - text = "TITLE", - isSelected = songSortBy == SongSortBy.Title, - onClick = { - isSortMenuDisplayed = false - songSortBy = SongSortBy.Title - } - ) - - DropDownTextItem( - text = "DATE ADDED", - isSelected = songSortBy == SongSortBy.DateAdded, - onClick = { - isSortMenuDisplayed = false - songSortBy = SongSortBy.DateAdded - } - ) - } - - DropDownSectionSpacer() - - DropDownSection { - DropDownTextItem( - text = when (songSortOrder) { - SortOrder.Ascending -> "ASCENDING" - SortOrder.Descending -> "DESCENDING" - }, - onClick = { - isSortMenuDisplayed = false - songSortOrder = !songSortOrder - } - ) - } - } - } - } - } - - itemsIndexed( - items = songCollection, - key = { _, song -> song.id }, - contentType = { _, song -> song } - ) { index, song -> - SongItem( - song = song, - thumbnailSize = thumbnailSize, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songCollection.map(DetailedSong::asMediaItem), - index - ) - }, - menuContent = { - InHistoryMediaItemMenu(song = song) - }, - onThumbnailContent = { - AnimatedVisibility( - visible = songSortBy == SongSortBy.PlayTime, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier - .align(Alignment.BottomCenter) - ) { - BasicText( - text = song.formattedTotalPlayTime, - style = typography.xxs.semiBold.center.color(Color.White), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.75f) - ) - ), - shape = ThumbnailRoundness.shape - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) - } - }, - modifier = Modifier - .animateItemPlacement() - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt deleted file mode 100644 index 675a8e0..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt +++ /dev/null @@ -1,269 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import android.net.Uri -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -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.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.SongPlaylistMap -import it.vfsfitvnm.vimusic.transaction -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError -import it.vfsfitvnm.vimusic.ui.components.themed.Menu -import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry -import it.vfsfitvnm.vimusic.ui.components.themed.TextCard -import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.relaunchableEffect -import it.vfsfitvnm.youtubemusic.YouTube -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -fun IntentUriScreen(uri: Uri) { - - val lazyListState = rememberLazyListState() - - var itemsResult by remember(uri) { - mutableStateOf>?>(null) - } - - var playlistBrowseId by rememberSaveable { - mutableStateOf(null) - } - - val onLoad = relaunchableEffect(uri) { - withContext(Dispatchers.IO) { - itemsResult = uri.getQueryParameter("list")?.let { playlistId -> - if (playlistId.startsWith("OLAK5uy_")) { - YouTube.queue(playlistId)?.map { songList -> - songList ?: emptyList() - } - } else { - playlistBrowseId = "VL$playlistId" - null - } - } ?: uri.getQueryParameter("v")?.let { videoId -> - YouTube.song(videoId)?.map { song -> - song?.let { listOf(song) } ?: emptyList() - } - } ?: uri.takeIf { - uri.host == "youtu.be" - }?.path?.drop(1)?.let { videoId -> - YouTube.song(videoId)?.map { song -> - song?.let { listOf(song) } ?: emptyList() - } - } ?: Result.failure(Error("Missing URL parameters")) - } - } - - playlistBrowseId?.let { browseId -> - PlaylistScreen(browseId = browseId) - return - } - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val menuState = LocalMenuState.current - val (colorPalette) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - - val thumbnailSizePx = Dimensions.thumbnails.song.px - - var isImportingAsPlaylist by remember(uri) { - mutableStateOf(false) - } - - - if (isImportingAsPlaylist) { - TextFieldDialog( - hintText = "Enter the playlist name", - onDismiss = { - isImportingAsPlaylist = false - }, - onDone = { text -> - menuState.hide() - - transaction { - val playlistId = Database.insert(Playlist(name = text)) - - itemsResult - ?.getOrNull() - ?.map(YouTube.Item.Song::asMediaItem) - ?.forEachIndexed { index, mediaItem -> - Database.insert(mediaItem) - - Database.insert( - SongPlaylistMap( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = index - ) - ) - } - } - } - ) - } - - LazyColumn( - state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - 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(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - menuState.display { - Menu { - MenuEntry( - icon = R.drawable.enqueue, - text = "Enqueue", - onClick = { - menuState.hide() - - itemsResult - ?.getOrNull() - ?.map(YouTube.Item.Song::asMediaItem) - ?.let { mediaItems -> - binder?.player?.enqueue( - mediaItems - ) - } - } - ) - - MenuEntry( - icon = R.drawable.playlist, - text = "Import as playlist", - onClick = { - isImportingAsPlaylist = true - } - ) - } - } - } - .padding(horizontal = 16.dp, vertical = 8.dp) - .size(24.dp) - ) - } - } - - itemsResult?.getOrNull()?.let { items -> - if (items.isEmpty()) { - item { - TextCard(icon = R.drawable.sad) { - Title(text = "No songs found") - Text(text = "Please try a different query or category.") - } - } - } else { - itemsIndexed( - items = items, - contentType = { _, item -> item } - ) { index, item -> - SmallSongItem( - song = item, - thumbnailSizePx = thumbnailSizePx, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - items.map(YouTube.Item.Song::asMediaItem), - index - ) - } - ) - } - } - } ?: itemsResult?.exceptionOrNull()?.let { throwable -> - item { - LoadingOrError( - errorMessage = throwable.javaClass.canonicalName, - onRetry = onLoad - ) - } - } ?: item { - LoadingOrError() - } - } - } - } -} - -@Composable -private fun LoadingOrError( - errorMessage: String? = null, - onRetry: (() -> Unit)? = null -) { - LoadingOrError( - errorMessage = errorMessage, - onRetry = onRetry - ) { - repeat(5) { index -> - SmallSongItemShimmer( - thumbnailSizeDp = Dimensions.thumbnails.song, - modifier = Modifier - .alpha(1f - index * 0.175f) - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 16.dp) - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/LocalPlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/LocalPlaylistScreen.kt deleted file mode 100644 index 66f4754..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/LocalPlaylistScreen.kt +++ /dev/null @@ -1,333 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import it.vfsfitvnm.reordering.ReorderingLazyColumn -import it.vfsfitvnm.reordering.animateItemPlacement -import it.vfsfitvnm.reordering.draggedItem -import it.vfsfitvnm.reordering.rememberReorderingState -import it.vfsfitvnm.reordering.reorder -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.models.PlaylistWithSongs -import it.vfsfitvnm.vimusic.models.SongPlaylistMap -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.transaction -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog -import it.vfsfitvnm.vimusic.ui.components.themed.InPlaylistMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.Menu -import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry -import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.toMediaItem -import it.vfsfitvnm.youtubemusic.YouTube -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun LocalPlaylistScreen(playlistId: Long) { - val playlistWithSongs by remember(playlistId) { - Database.playlistWithSongs(playlistId).map { it ?: PlaylistWithSongs.NotFound } - }.collectAsState(initial = PlaylistWithSongs.Empty, context = Dispatchers.IO) - - val lazyListState = rememberLazyListState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val (colorPalette, typography) = LocalAppearance.current - val menuState = LocalMenuState.current - val binder = LocalPlayerServiceBinder.current - - val thumbnailSize = Dimensions.thumbnails.song.px - - val reorderingState = rememberReorderingState( - lazyListState = lazyListState, - key = playlistWithSongs.songs, - onDragEnd = { fromIndex, toIndex -> - query { - Database.move(playlistWithSongs.playlist.id, fromIndex, toIndex) - } - }, - extraItemCount = 1 - ) - - var isRenaming by rememberSaveable { - mutableStateOf(false) - } - - if (isRenaming) { - TextFieldDialog( - hintText = "Enter the playlist name", - initialTextInput = playlistWithSongs.playlist.name, - onDismiss = { isRenaming = false }, - onDone = { text -> - query { - Database.update(playlistWithSongs.playlist.copy(name = text)) - } - } - ) - } - - var isDeleting by rememberSaveable { - mutableStateOf(false) - } - - if (isDeleting) { - ConfirmationDialog( - text = "Do you really want to delete this playlist?", - onDismiss = { isDeleting = false }, - onConfirm = { - query { - Database.delete(playlistWithSongs.playlist) - } - pop() - } - ) - } - - ReorderingLazyColumn( - reorderingState = reorderingState, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - Column { - 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(vertical = 8.dp, horizontal = 16.dp) - .size(24.dp) - ) - } - - Column( - modifier = Modifier - .padding(top = 16.dp, bottom = 8.dp) - .padding(horizontal = 16.dp) - ) { - BasicText( - text = playlistWithSongs.playlist.name, - style = typography.m.semiBold - ) - - BasicText( - text = "${playlistWithSongs.songs.size} songs", - style = typography.xxs.semiBold.secondary - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier - .fillMaxWidth() - .zIndex(1f) - .padding(horizontal = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(enabled = playlistWithSongs.songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - playlistWithSongs.songs - .shuffled() - .map(DetailedSong::asMediaItem) - ) - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - menuState.display { - Menu { - MenuEntry( - icon = R.drawable.enqueue, - text = "Enqueue", - isEnabled = playlistWithSongs.songs.isNotEmpty(), - onClick = { - menuState.hide() - binder?.player?.enqueue( - playlistWithSongs.songs.map( - DetailedSong::asMediaItem - ) - ) - } - ) - - MenuEntry( - icon = R.drawable.pencil, - text = "Rename", - onClick = { - menuState.hide() - isRenaming = true - } - ) - - playlistWithSongs.playlist.browseId?.let { browseId -> - MenuEntry( - icon = R.drawable.sync, - text = "Sync", - onClick = { - menuState.hide() - transaction { - runBlocking(Dispatchers.IO) { - withContext(Dispatchers.IO) { - YouTube.playlist(browseId)?.map { - it.next() - }?.map { playlist -> - playlist.copy(items = playlist.items?.filter { it.info.endpoint != null }) - } - } - }?.getOrNull()?.let { remotePlaylist -> - Database.clearPlaylist(playlistWithSongs.playlist.id) - - remotePlaylist.items?.forEachIndexed { index, song -> - song.toMediaItem(browseId, remotePlaylist)?.let { mediaItem -> - Database.insert(mediaItem) - - Database.insert( - SongPlaylistMap( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = index - ) - ) - } - } - } - } - } - ) - } - - MenuEntry( - icon = R.drawable.trash, - text = "Delete", - onClick = { - menuState.hide() - isDeleting = true - } - ) - } - } - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - } - } - } - - itemsIndexed( - items = playlistWithSongs.songs, - key = { _, song -> song.id }, - contentType = { _, song -> song }, - ) { index, song -> - SongItem( - song = song, - thumbnailSize = thumbnailSize, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - playlistWithSongs.songs.map( - DetailedSong::asMediaItem - ), index - ) - }, - menuContent = { - InPlaylistMediaItemMenu( - playlistId = playlistId, - positionInPlaylist = index, - song = song - ) - }, - trailingContent = { - Image( - painter = painterResource(R.drawable.reorder), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textSecondary), - modifier = Modifier - .clickable { } - .reorder( - reorderingState = reorderingState, - index = index - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - .size(20.dp) - ) - }, - modifier = Modifier - .animateItemPlacement(reorderingState = reorderingState) - .draggedItem(reorderingState = reorderingState, index = index) - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt deleted file mode 100644 index 9a0e48c..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt +++ /dev/null @@ -1,457 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import android.content.Intent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import coil.compose.AsyncImage -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.SongPlaylistMap -import it.vfsfitvnm.vimusic.transaction -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError -import it.vfsfitvnm.vimusic.ui.components.themed.Menu -import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.bold -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.relaunchableEffect -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.toMediaItem -import it.vfsfitvnm.youtubemusic.YouTube -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -fun PlaylistScreen(browseId: String) { - val lazyListState = rememberLazyListState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val context = LocalContext.current - val binder = LocalPlayerServiceBinder.current - - val (colorPalette, typography) = LocalAppearance.current - val menuState = LocalMenuState.current - - val thumbnailSizePx = Dimensions.thumbnails.playlist.px - val songThumbnailSizePx = Dimensions.thumbnails.song.px - - var playlist by remember { - mutableStateOf?>(null) - } - - val onLoad = relaunchableEffect(Unit) { - playlist = withContext(Dispatchers.IO) { - YouTube.playlist(browseId)?.map { - it.next() - }?.map { playlist -> - playlist.copy(items = playlist.items?.filter { it.info.endpoint != null }) - } - } - } - - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - 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(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - } - } - - item { - playlist?.getOrNull()?.let { playlist -> - Column { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Max) - .padding(vertical = 8.dp, horizontal = 16.dp) - .padding(bottom = 8.dp) - ) { - AsyncImage( - model = playlist.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(Dimensions.thumbnails.playlist) - ) - - Column( - verticalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxSize() - ) { - Column { - BasicText( - text = playlist.title ?: "Unknown", - style = typography.m.semiBold - ) - - BasicText( - text = playlist.authors?.joinToString("") { it.name } - ?: "", - style = typography.xs.secondary.semiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - - playlist.year?.let { year -> - BasicText( - text = year, - style = typography.xs.secondary, - maxLines = 1, - modifier = Modifier - .padding(top = 8.dp) - ) - } - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier - .fillMaxWidth() - .zIndex(1f) - .padding(horizontal = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - binder?.stopRadio() - playlist.items - ?.shuffled() - ?.mapNotNull { song -> - song.toMediaItem(browseId, playlist) - } - ?.let { mediaItems -> - binder?.player?.forcePlayFromBeginning( - mediaItems - ) - } - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - menuState.display { - Menu { - MenuEntry( - icon = R.drawable.enqueue, - text = "Enqueue", - onClick = { - menuState.hide() - playlist.items - ?.mapNotNull { song -> - song.toMediaItem( - browseId, - playlist - ) - } - ?.let { mediaItems -> - binder?.player?.enqueue( - mediaItems - ) - } - } - ) - - MenuEntry( - icon = R.drawable.playlist, - text = "Import", - onClick = { - menuState.hide() - transaction { - val playlistId = - Database.insert( - Playlist( - name = playlist.title - ?: "Unknown", - browseId = browseId - ) - ) - - playlist.items?.forEachIndexed { index, song -> - song - .toMediaItem( - browseId, - playlist - ) - ?.let { mediaItem -> - Database.insert( - mediaItem - ) - - Database.insert( - SongPlaylistMap( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = index - ) - ) - } - } - } - } - ) - - MenuEntry( - icon = R.drawable.share_social, - text = "Share", - onClick = { - menuState.hide() - - (playlist.url - ?: "https://music.youtube.com/playlist?list=${ - browseId.removePrefix( - "VL" - ) - }").let { url -> - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - } - - context.startActivity( - Intent.createChooser( - sendIntent, - null - ) - ) - } - } - ) - } - } - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - } - } - } ?: playlist?.exceptionOrNull()?.let { throwable -> - LoadingOrError( - errorMessage = throwable.javaClass.canonicalName, - onRetry = onLoad - ) - } ?: LoadingOrError() - } - - itemsIndexed( - items = playlist?.getOrNull()?.items ?: emptyList(), - contentType = { _, song -> song } - ) { index, song -> - SongItem( - title = song.info.name, - authors = (song.authors - ?: playlist?.getOrNull()?.authors)?.joinToString("") { it.name }, - durationText = song.durationText, - onClick = { - binder?.stopRadio() - playlist?.getOrNull()?.items?.mapNotNull { song -> - song.toMediaItem(browseId, playlist?.getOrNull()!!) - }?.let { mediaItems -> - binder?.player?.forcePlayAtIndex(mediaItems, index) - } - }, - startContent = { - if (song.thumbnail == null) { - BasicText( - text = "${index + 1}", - style = typography.xs.secondary.bold.center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .width(36.dp) - ) - } else { - AsyncImage( - model = song.thumbnail!!.size(songThumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(Dimensions.thumbnails.song) - ) - } - }, - menuContent = { - NonQueuedMediaItemMenu( - mediaItem = song.toMediaItem( - browseId, - playlist?.getOrNull()!! - ) - ?: return@SongItem, - onDismiss = menuState::hide, - ) - } - ) - } - } - } - } -} - -@Composable -private fun LoadingOrError( - errorMessage: String? = null, - onRetry: (() -> Unit)? = null -) { - val (colorPalette) = LocalAppearance.current - - LoadingOrError( - errorMessage = errorMessage, - onRetry = onRetry - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .height(IntrinsicSize.Max) - .padding(vertical = 8.dp, horizontal = 16.dp) - .padding(bottom = 16.dp) - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = ThumbnailRoundness.shape) - .size(Dimensions.thumbnails.playlist) - ) - - Column( - verticalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxHeight() - ) { - Column { - TextPlaceholder() - - TextPlaceholder( - modifier = Modifier - .alpha(0.7f) - ) - } - } - } - - repeat(3) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .alpha(0.6f - it * 0.1f) - .height(Dimensions.thumbnails.song) - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 16.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(36.dp) - ) { - Spacer( - modifier = Modifier - .size(8.dp) - .background(color = Color.Black, shape = CircleShape) - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - TextPlaceholder() - - TextPlaceholder( - modifier = Modifier - .alpha(0.7f) - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt index 338bba5..f70cb89 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt @@ -1,34 +1,30 @@ package it.vfsfitvnm.vimusic.ui.screens import android.annotation.SuppressLint -import android.net.Uri import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import it.vfsfitvnm.route.Route0 import it.vfsfitvnm.route.Route1 import it.vfsfitvnm.route.RouteHandlerScope import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist +import it.vfsfitvnm.vimusic.ui.screens.album.AlbumScreen +import it.vfsfitvnm.vimusic.ui.screens.artist.ArtistScreen +import it.vfsfitvnm.vimusic.ui.screens.playlist.PlaylistScreen -val aboutRoute = Route0("aboutRoute") val albumRoute = Route1("albumRoute") -val appearanceSettingsRoute = Route0("appearanceSettingsRoute") val artistRoute = Route1("artistRoute") -val backupAndRestoreRoute = Route0("backupAndRestoreRoute") val builtInPlaylistRoute = Route1("builtInPlaylistRoute") -val cacheSettingsRoute = Route0("cacheSettingsRoute") -val intentUriRoute = Route1("intentUriRoute") val localPlaylistRoute = Route1("localPlaylistRoute") -val otherSettingsRoute = Route0("otherSettingsRoute") -val playerSettingsRoute = Route0("playerSettingsRoute") val playlistRoute = Route1("playlistRoute") val searchResultRoute = Route1("searchResultRoute") val searchRoute = Route1("searchRoute") val settingsRoute = Route0("settingsRoute") -val viewPlaylistsRoute = Route0("createPlaylistRoute") @SuppressLint("ComposableNaming") @Suppress("NOTHING_TO_INLINE") @ExperimentalAnimationApi +@ExperimentalFoundationApi @Composable inline fun RouteHandlerScope.globalRoutes() { albumRoute { browseId -> @@ -42,4 +38,10 @@ inline fun RouteHandlerScope.globalRoutes() { browseId = browseId ?: error("browseId cannot be null") ) } + + playlistRoute { browseId -> + PlaylistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt deleted file mode 100644 index a0f5973..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt +++ /dev/null @@ -1,601 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.ui.components.ChipGroup -import it.vfsfitvnm.vimusic.ui.components.ChipItem -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.TextCard -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.relaunchableEffect -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.searchFilterKey -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.youtubemusic.YouTube -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { - val (colorPalette, typography) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - - var searchFilter by rememberPreference(searchFilterKey, YouTube.Item.Song.Filter.value) - - val lazyListState = rememberLazyListState() - - val items = remember(searchFilter) { - mutableStateListOf() - } - - var continuationResult by remember(searchFilter) { - mutableStateOf?>(null) - } - - val onLoad = relaunchableEffect(searchFilter) { - withContext(Dispatchers.Main) { - val token = continuationResult?.getOrNull() - - continuationResult = null - - continuationResult = withContext(Dispatchers.IO) { - YouTube.search(query, searchFilter, token) - }?.map { searchResult -> - items.addAll(searchResult.items) - searchResult.continuation - } - } - } - - val thumbnailSizePx = Dimensions.thumbnails.song.px - - RouteHandler(listenToGlobalEmitter = true) { - albumRoute { browseId -> - AlbumScreen( - browseId = browseId ?: "browseId cannot be null" - ) - } - - artistRoute { browseId -> - ArtistScreen( - browseId = browseId ?: "browseId cannot be null" - ) - } - - playlistRoute { browseId -> - PlaylistScreen( - browseId = browseId ?: "browseId cannot be null" - ) - } - - host { - LazyColumn( - state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - 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(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - - BasicText( - text = query, - style = typography.m.semiBold.center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onSearchAgain - ) - .weight(1f) - ) - - Spacer( - modifier = Modifier - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - } - } - - item { - ChipGroup( - items = listOf( - ChipItem( - text = "Songs", - value = YouTube.Item.Song.Filter.value - ), - ChipItem( - text = "Albums", - value = YouTube.Item.Album.Filter.value - ), - ChipItem( - text = "Artists", - value = YouTube.Item.Artist.Filter.value - ), - ChipItem( - text = "Videos", - value = YouTube.Item.Video.Filter.value - ), - ChipItem( - text = "Playlists", - value = YouTube.Item.CommunityPlaylist.Filter.value - ), - ChipItem( - text = "Featured playlists", - value = YouTube.Item.FeaturedPlaylist.Filter.value - ), - ), - value = searchFilter, - selectedBackgroundColor = colorPalette.accent, - unselectedBackgroundColor = colorPalette.background1, - selectedTextStyle = typography.xs.medium.color(colorPalette.onAccent), - unselectedTextStyle = typography.xs.medium, - shape = RoundedCornerShape(36.dp), - onValueChanged = { - searchFilter = it - }, - modifier = Modifier - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - ) - } - - items( - items = items, - contentType = { it } - ) { item -> - SmallItem( - item = item, - thumbnailSizeDp = Dimensions.thumbnails.song, - thumbnailSizePx = thumbnailSizePx, - onClick = { - when (item) { - is YouTube.Item.Album -> albumRoute(item.info.endpoint!!.browseId) - is YouTube.Item.Artist -> artistRoute(item.info.endpoint!!.browseId) - is YouTube.Item.Playlist -> playlistRoute(item.info.endpoint!!.browseId) - is YouTube.Item.Song -> { - binder?.stopRadio() - binder?.player?.forcePlay(item.asMediaItem) - binder?.setupRadio(item.info.endpoint) - } - is YouTube.Item.Video -> { - binder?.stopRadio() - binder?.player?.forcePlay(item.asMediaItem) - binder?.setupRadio(item.info.endpoint) - } - } - } - ) - } - - continuationResult?.getOrNull()?.let { - if (items.isNotEmpty()) { - item { - SideEffect(onLoad) - } - } - } ?: continuationResult?.exceptionOrNull()?.let { throwable -> - item { - LoadingOrError( - errorMessage = throwable.javaClass.canonicalName, - onRetry = onLoad - ) - } - } ?: continuationResult?.let { - if (items.isEmpty()) { - item { - TextCard(icon = R.drawable.sad) { - Title(text = "No results found") - Text(text = "Please try a different query or category.") - } - } - } - } ?: item(key = "loading") { - LoadingOrError( - itemCount = if (items.isEmpty()) 8 else 3, - isLoadingArtists = searchFilter == YouTube.Item.Artist.Filter.value - ) - } - } - } - } -} - -@Composable -fun SmallSongItemShimmer( - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier -) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(thumbnailSizeDp) - ) - - Column { - TextPlaceholder() - TextPlaceholder() - } - } -} - -@Composable -fun SmallArtistItemShimmer( - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier -) { - val (colorPalette) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = CircleShape) - .size(thumbnailSizeDp) - ) - - TextPlaceholder() - } -} - -@ExperimentalAnimationApi -@Composable -fun SmallItem( - item: YouTube.Item, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - when (item) { - is YouTube.Item.Artist -> SmallArtistItem( - artist = item, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - modifier = modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick - ) - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - is YouTube.Item.Song -> SmallSongItem( - song = item, - thumbnailSizePx = thumbnailSizePx, - onClick = onClick, - modifier = modifier - ) - is YouTube.Item.Album -> SmallAlbumItem( - album = item, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - modifier = modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick - ) - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - is YouTube.Item.Video -> SmallVideoItem( - video = item, - thumbnailSizePx = thumbnailSizePx, - onClick = onClick, - modifier = modifier - ) - is YouTube.Item.Playlist -> SmallPlaylistItem( - playlist = item, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - modifier = modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick - ) - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - } -} - -@ExperimentalAnimationApi -@Composable -fun SmallSongItem( - song: YouTube.Item.Song, - thumbnailSizePx: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - SongItem( - thumbnailModel = song.thumbnail?.size(thumbnailSizePx), - title = song.info.name, - authors = song.authors.joinToString("") { it.name }, - durationText = song.durationText, - onClick = onClick, - menuContent = { - NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) - }, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun SmallVideoItem( - video: YouTube.Item.Video, - thumbnailSizePx: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - SongItem( - thumbnailModel = video.thumbnail?.size(thumbnailSizePx), - title = video.info.name, - authors = (if (video.isOfficialMusicVideo) video.authors else video.views) - .joinToString("") { it.name }, - durationText = video.durationText, - onClick = onClick, - menuContent = { - NonQueuedMediaItemMenu(mediaItem = video.asMediaItem) - }, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun SmallPlaylistItem( - playlist: YouTube.Item.Playlist, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - modifier: Modifier = Modifier -) { - val (_, typography) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier - ) { - AsyncImage( - model = playlist.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(thumbnailSizeDp) - ) - - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = playlist.info.name, - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = playlist.channel?.name ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - playlist.songCount?.let { songCount -> - BasicText( - text = "$songCount songs", - style = typography.xxs.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -fun SmallAlbumItem( - album: YouTube.Item.Album, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - modifier: Modifier = Modifier, -) { - val (_, typography) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier - ) { - AsyncImage( - model = album.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(thumbnailSizeDp) - ) - - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = album.info.name, - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = album.authors?.joinToString("") { it.name } ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - album.year?.let { year -> - BasicText( - text = year, - style = typography.xxs.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -fun SmallArtistItem( - artist: YouTube.Item.Artist, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - modifier: Modifier = Modifier, -) { - val (_, typography) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier - ) { - AsyncImage( - model = artist.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - modifier = Modifier - .clip(CircleShape) - .size(thumbnailSizeDp) - ) - - BasicText( - text = artist.info.name, - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - ) - } -} - -@Composable -private fun LoadingOrError( - itemCount: Int = 0, - isLoadingArtists: Boolean = false, - errorMessage: String? = null, - onRetry: (() -> Unit)? = null -) { - LoadingOrError( - errorMessage = errorMessage, - onRetry = onRetry, - horizontalAlignment = Alignment.CenterHorizontally - ) { - repeat(itemCount) { index -> - if (isLoadingArtists) { - SmallArtistItemShimmer( - thumbnailSizeDp = Dimensions.thumbnails.song, - modifier = Modifier - .alpha(1f - index * 0.125f) - .fillMaxWidth() - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - } else { - SmallSongItemShimmer( - thumbnailSizeDp = Dimensions.thumbnails.song, - modifier = Modifier - .alpha(1f - index * 0.125f) - .fillMaxWidth() - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt deleted file mode 100644 index 790044f..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt +++ /dev/null @@ -1,400 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import android.net.Uri -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.paint -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.SearchQuery -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.youtubemusic.YouTube -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -fun SearchScreen(initialTextInput: String, onSearch: (String) -> Unit, onUri: (Uri) -> Unit) { - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val (colorPalette, typography) = LocalAppearance.current - val layoutDirection = LocalLayoutDirection.current - val paddingValues = WindowInsets.systemBars.asPaddingValues() - - val timeIconPainter = painterResource(R.drawable.time) - val closeIconPainter = painterResource(R.drawable.close) - val arrowForwardIconPainter = painterResource(R.drawable.arrow_forward) - val rippleIndication = rememberRipple(bounded = true) - - var textFieldValue by rememberSaveable( - initialTextInput, - stateSaver = TextFieldValue.Saver - ) { - mutableStateOf( - TextFieldValue( - text = initialTextInput, - selection = TextRange(initialTextInput.length) - ) - ) - } - - val focusRequester = remember { - FocusRequester() - } - - val searchSuggestionsResult by produceState?>?>( - initialValue = null, - key1 = textFieldValue - ) { - value = if (textFieldValue.text.isNotEmpty()) { - withContext(Dispatchers.IO) { - YouTube.getSearchSuggestions(textFieldValue.text) - } - } else { - null - } - } - - val history by remember(textFieldValue.text) { - Database.queries("%${textFieldValue.text}%").distinctUntilChanged { old, new -> - old.size == new.size - } - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - val isOpenableUrl = remember(textFieldValue.text) { - listOf( - "https://www.youtube.com/watch?", - "https://music.youtube.com/watch?", - "https://m.youtube.com/watch?", - "https://www.youtube.com/playlist?", - "https://music.youtube.com/playlist?", - "https://m.youtube.com/playlist?", - "https://youtu.be/", - ).any(textFieldValue.text::startsWith) - } - - LaunchedEffect(Unit) { - delay(300) - focusRequester.requestFocus() - } - - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding( - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - top = paddingValues.calculateTopPadding(), - ) - ) { - TopAppBar( - modifier = Modifier - .height(52.dp) - ) { - BasicTextField( - value = textFieldValue, - onValueChange = { - textFieldValue = it - }, - textStyle = typography.m.medium, - singleLine = true, - maxLines = 1, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions( - onSearch = { - if (textFieldValue.text.isNotEmpty()) { - onSearch(textFieldValue.text) - } - } - ), - cursorBrush = SolidColor(colorPalette.text), - decorationBox = { innerTextField -> - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - painter = painterResource(R.drawable.chevron_back), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - pop() - focusRequester.freeFocus() - } - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - - Box( - modifier = Modifier - .weight(1f) - ) { - androidx.compose.animation.AnimatedVisibility( - visible = textFieldValue.text.isEmpty(), - enter = fadeIn(tween(100)), - exit = fadeOut(tween(100)), - ) { - BasicText( - text = "Enter a song, an album, an artist name...", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = typography.m.secondary, - ) - } - - innerTextField() - } - - Box( - modifier = Modifier - .clickable { - textFieldValue = TextFieldValue() - } - .padding(horizontal = 14.dp, vertical = 6.dp) - .background( - color = colorPalette.background1, - shape = CircleShape - ) - .size(28.dp) - ) { - Image( - painter = painterResource(R.drawable.close), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textSecondary), - modifier = Modifier - .align(Alignment.Center) - .size(14.dp) - ) - } - } - }, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester) - ) - } - - if (isOpenableUrl) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { onUri(textFieldValue.text.toUri()) } - ) - .fillMaxWidth() - .background(colorPalette.background1) - .padding(vertical = 16.dp, horizontal = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.link), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textDisabled), - modifier = Modifier - .padding(horizontal = 8.dp) - .size(20.dp) - ) - - BasicText( - text = "Open URL", - style = typography.s.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - } - } - - LazyColumn( - contentPadding = PaddingValues( - bottom = Dimensions.collapsedPlayer + paddingValues.calculateBottomPadding() - ) - ) { - items( - items = history, - key = SearchQuery::id - ) { searchQuery -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { onSearch(searchQuery.query) } - ) - .fillMaxWidth() - .padding(vertical = 16.dp, horizontal = 8.dp) - ) { - Spacer( - modifier = Modifier - .padding(horizontal = 8.dp) - .size(20.dp) - .paint( - painter = timeIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - - BasicText( - text = searchQuery.query, - style = typography.s.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - - Spacer( - modifier = Modifier - .clickable { - query { - Database.delete(searchQuery) - } - } - .padding(horizontal = 8.dp) - .size(20.dp) - .paint( - painter = closeIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - - Spacer( - modifier = Modifier - .clickable { - textFieldValue = TextFieldValue( - text = searchQuery.query, - selection = TextRange(searchQuery.query.length) - ) - } - .rotate(225f) - .padding(horizontal = 8.dp) - .size(20.dp) - .paint( - painter = arrowForwardIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - } - } - - searchSuggestionsResult?.getOrNull()?.let { suggestions -> - items(items = suggestions) { suggestion -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { onSearch(suggestion) } - ) - .fillMaxWidth() - .padding(vertical = 16.dp, horizontal = 8.dp) - ) { - Spacer( - modifier = Modifier - .padding(horizontal = 8.dp) - .size(20.dp) - ) - - BasicText( - text = suggestion, - style = typography.s.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - - Spacer( - modifier = Modifier - .clickable { - textFieldValue = TextFieldValue( - text = suggestion, - selection = TextRange(suggestion.length) - ) - } - .rotate(225f) - .padding(horizontal = 8.dp) - .size(22.dp) - .paint( - painter = arrowForwardIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - } - } - } ?: searchSuggestionsResult?.exceptionOrNull()?.let { throwable -> - item { - LoadingOrError(errorMessage = throwable.javaClass.canonicalName) {} - } - } - } - } - } - } -} 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 deleted file mode 100644 index f26b944..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt +++ /dev/null @@ -1,425 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import androidx.annotation.DrawableRes -import androidx.compose.animation.* -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.* -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.route.* -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.badge -import it.vfsfitvnm.vimusic.ui.components.themed.Switch -import it.vfsfitvnm.vimusic.ui.components.themed.ValueSelectorDialog -import it.vfsfitvnm.vimusic.ui.screens.settings.* -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.* - -@ExperimentalAnimationApi -@Composable -fun SettingsScreen() { - val scrollState = rememberScrollState() - - RouteHandler( - listenToGlobalEmitter = true, - transitionSpec = { - when (targetState.route) { - albumRoute, artistRoute -> fastFade - else -> when (initialState.route) { - albumRoute, artistRoute -> fastFade - null -> leftSlide - else -> rightSlide - } - } - } - ) { - globalRoutes() - - appearanceSettingsRoute { - AppearanceSettingsScreen() - } - - playerSettingsRoute { - PlayerSettingsScreen() - } - - backupAndRestoreRoute { - BackupAndRestoreScreen() - } - - cacheSettingsRoute { - CacheSettingsScreen() - } - - otherSettingsRoute { - OtherSettingsScreen() - } - - aboutRoute { - AboutScreen() - } - - host { - val (colorPalette, typography) = LocalAppearance.current - - var isFirstLaunch by rememberPreference(isFirstLaunchKey, true) - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(scrollState) - .padding(LocalPlayerAwarePaddingValues.current) - ) { - 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 = "Settings", - style = typography.l.semiBold, - modifier = Modifier - .padding(start = 48.dp) - .padding(all = 16.dp) - ) - - @Composable - fun Entry( - @DrawableRes icon: Int, - color: Color, - title: String, - description: String, - route: Route0, - withAlert: Boolean = false, - onClick: (() -> Unit)? = null - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { - route() - onClick?.invoke() - } - ) - .padding(horizontal = 16.dp, vertical = 12.dp) - .fillMaxWidth() - ) { - Box( - modifier = Modifier - .background(color = color, shape = CircleShape) - .size(36.dp) - .badge(color = colorPalette.red, isDisplayed = withAlert) - ) { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(16.dp) - ) - } - - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = title, - style = typography.s.semiBold, - ) - - BasicText( - text = description, - style = typography.xs.secondary.medium, - maxLines = 2 - ) - } - } - } - - Entry( - color = colorPalette.background2, - icon = R.drawable.color_palette, - title = "Appearance", - description = "Change the colors and shapes", - route = appearanceSettingsRoute, - ) - - Entry( - color = colorPalette.background2, - icon = R.drawable.play, - title = "Player & Audio", - description = "Player and audio settings", - route = playerSettingsRoute, - ) - - Entry( - color = colorPalette.background2, - icon = R.drawable.server, - title = "Cache", - description = "Manage the used space", - route = cacheSettingsRoute - ) - - Entry( - color = colorPalette.background2, - icon = R.drawable.save, - title = "Backup & Restore", - description = "Backup and restore the database", - route = backupAndRestoreRoute - ) - - Entry( - color = colorPalette.background2, - icon = R.drawable.shapes, - title = "Other", - description = "Advanced settings", - route = otherSettingsRoute, - withAlert = isFirstLaunch, - onClick = { - isFirstLaunch = false - } - ) - - Entry( - color = colorPalette.background2, - icon = R.drawable.information, - title = "About", - description = "App version and social links", - route = aboutRoute - ) - } - } - } -} - -@Composable -inline fun > EnumValueSelectorSettingsEntry( - title: String, - selectedValue: T, - crossinline onValueSelected: (T) -> Unit, - modifier: Modifier = Modifier, - isEnabled: Boolean = true, - crossinline valueText: (T) -> String = Enum::name -) { - ValueSelectorSettingsEntry( - title = title, - selectedValue = selectedValue, - values = enumValues().toList(), - onValueSelected = onValueSelected, - modifier = modifier, - isEnabled = isEnabled, - valueText = valueText - ) -} - -@Composable -inline fun ValueSelectorSettingsEntry( - title: String, - selectedValue: T, - values: List, - crossinline onValueSelected: (T) -> Unit, - modifier: Modifier = Modifier, - isEnabled: Boolean = true, - crossinline valueText: (T) -> String = { it.toString() } -) { - var isShowingDialog by remember { - mutableStateOf(false) - } - - if (isShowingDialog) { - ValueSelectorDialog( - onDismiss = { - isShowingDialog = false - }, - title = title, - selectedValue = selectedValue, - values = values, - onValueSelected = onValueSelected, - valueText = valueText - ) - } - - SettingsEntry( - title = title, - text = valueText(selectedValue), - modifier = modifier, - isEnabled = isEnabled, - onClick = { - isShowingDialog = true - } - ) -} - -@Composable -fun SwitchSettingEntry( - title: String, - text: String, - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - isEnabled: Boolean = true -) { - val (colorPalette, typography) = LocalAppearance.current - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { onCheckedChange(!isChecked) }, - enabled = isEnabled - ) - .alpha(if (isEnabled) 1f else 0.5f) - .padding(start = 24.dp) - .padding(horizontal = 32.dp, vertical = 16.dp) - .fillMaxWidth() - ) { - - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = title, - style = typography.xs.semiBold.copy(color = colorPalette.text), - ) - - BasicText( - text = text, - style = typography.xs.semiBold.copy(color = colorPalette.textSecondary), - ) - } - - Switch(isChecked = isChecked) - } -} - -@Composable -fun SettingsEntry( - title: String, - text: String, - modifier: Modifier = Modifier, - onClick: () -> Unit, - isEnabled: Boolean = true -) { - val (_, typography) = LocalAppearance.current - val (colorPalette) = LocalAppearance.current - - Column( - modifier = modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick, - enabled = isEnabled - ) - .alpha(if (isEnabled) 1f else 0.5f) - .padding(start = 24.dp) - .padding(horizontal = 32.dp, vertical = 16.dp) - .fillMaxWidth() - ) { - BasicText( - text = title, - style = typography.xs.semiBold.copy(color = colorPalette.text), - ) - - BasicText( - text = text, - style = typography.xs.semiBold.copy(color = colorPalette.textSecondary), - ) - } -} - -@Composable -fun SettingsTitle( - text: String, - modifier: Modifier = Modifier, -) { - val (_, typography) = LocalAppearance.current - - BasicText( - text = text, - style = typography.m.semiBold, - modifier = modifier - .padding(start = 40.dp) - .padding(all = 16.dp) - ) -} - -@Composable -fun SettingsDescription( - text: String, - modifier: Modifier = Modifier, -) { - val (_, typography) = LocalAppearance.current - - BasicText( - text = text, - style = typography.xxs.secondary, - modifier = modifier - .padding(start = 56.dp, end = 24.dp) - .padding(bottom = 16.dp) - ) -} - -@Composable -fun SettingsGroupDescription( - text: String, - modifier: Modifier = Modifier, -) { - val (_, typography) = LocalAppearance.current - - BasicText( - text = text, - style = typography.xxs.secondary, - modifier = modifier - .padding(start = 56.dp, end = 24.dp) - .padding(vertical = 8.dp) - ) -} - -@Composable -fun SettingsEntryGroupText( - title: String, - modifier: Modifier = Modifier, -) { - val (colorPalette, typography) = LocalAppearance.current - - BasicText( - text = title.uppercase(), - style = typography.xxs.semiBold.copy(colorPalette.accent), - modifier = modifier - .padding(start = 24.dp, top = 24.dp) - .padding(horizontal = 32.dp) - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt new file mode 100644 index 0000000..6401e4f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt @@ -0,0 +1,240 @@ +package it.vfsfitvnm.vimusic.ui.screens.album + +import android.content.Intent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.Album +import it.vfsfitvnm.vimusic.models.SongAlbumMap +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.savers.AlbumSaver +import it.vfsfitvnm.vimusic.savers.InnertubeAlbumItemListSaver +import it.vfsfitvnm.vimusic.savers.InnertubePlaylistOrAlbumPageSaver +import it.vfsfitvnm.vimusic.savers.innertubeItemsPageSaver +import it.vfsfitvnm.vimusic.savers.nullableSaver +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent +import it.vfsfitvnm.vimusic.ui.items.AlbumItem +import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder +import it.vfsfitvnm.vimusic.ui.screens.albumRoute +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes +import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.requests.albumPage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun AlbumScreen(browseId: String) { + val saveableStateHolder = rememberSaveableStateHolder() + + val (tabIndex, onTabChanged) = rememberSaveable { + mutableStateOf(0) + } + + val album by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(AlbumSaver), + ) { + Database + .album(browseId) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val innertubeAlbum by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(InnertubePlaylistOrAlbumPageSaver), + tabIndex > 0 + ) { + if (value != null || (tabIndex == 0 && withContext(Dispatchers.IO) { + Database.albumTimestamp( + browseId + ) + } != null)) return@produceSaveableState + + withContext(Dispatchers.IO) { + Innertube.albumPage(BrowseBody(browseId = browseId)) + }?.onSuccess { albumPage -> + value = albumPage + + query { + Database.upsert( + Album( + id = browseId, + title = albumPage.title, + thumbnailUrl = albumPage.thumbnail?.url, + year = albumPage.year, + authorsText = albumPage.authors?.joinToString("") { it.name ?: "" }, + shareUrl = albumPage.url, + timestamp = System.currentTimeMillis(), + bookmarkedAt = album?.bookmarkedAt + ), + albumPage + .songsPage + ?.items + ?.map(Innertube.SongItem::asMediaItem) + ?.onEach(Database::insert) + ?.mapIndexed { position, mediaItem -> + SongAlbumMap( + songId = mediaItem.mediaId, + albumId = browseId, + position = position + ) + } ?: emptyList() + ) + } + } + } + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = + { textButton -> + if (album?.timestamp == null) { + HeaderPlaceholder( + modifier = Modifier + .shimmer() + ) + } else { + val (colorPalette) = LocalAppearance.current + val context = LocalContext.current + + Header(title = album?.title ?: "Unknown") { + textButton?.invoke() + + Spacer( + modifier = Modifier + .weight(1f) + ) + + HeaderIconButton( + icon = if (album?.bookmarkedAt == null) { + R.drawable.bookmark_outline + } else { + R.drawable.bookmark + }, + color = colorPalette.accent, + onClick = { + val bookmarkedAt = + if (album?.bookmarkedAt == null) System.currentTimeMillis() else null + + query { + album + ?.copy(bookmarkedAt = bookmarkedAt) + ?.let(Database::update) + } + } + ) + + HeaderIconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + album?.shareUrl?.let { url -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + + context.startActivity( + Intent.createChooser( + sendIntent, + null + ) + ) + } + } + ) + } + } + } + + val thumbnailContent = + adaptiveThumbnailContent(album?.timestamp == null, album?.thumbnailUrl) + + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChanged = onTabChanged, + tabColumnContent = { Item -> + Item(0, "Songs", R.drawable.musical_notes) + Item(1, "Other versions", R.drawable.disc) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> AlbumSongs( + browseId = browseId, + headerContent = headerContent, + thumbnailContent = thumbnailContent, + ) + + 1 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = innertubeItemsPageSaver(InnertubeAlbumItemListSaver), + headerContent = headerContent, + initialPlaceholderCount = 1, + continuationPlaceholderCount = 1, + emptyItemsText = "This album doesn't have any alternative version", + itemsPageProvider = innertubeAlbum?.let { + ({ + Result.success( + Innertube.ItemsPage( + items = innertubeAlbum?.otherVersions, + continuation = null + ) + ) + }) + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable { albumRoute(album.key) } + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt new file mode 100644 index 0000000..175de83 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt @@ -0,0 +1,177 @@ +package it.vfsfitvnm.vimusic.ui.screens.album + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.ShimmerHost +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.enqueue +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning +import it.vfsfitvnm.vimusic.utils.isLandscape +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.semiBold +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@Composable +fun AlbumSongs( + browseId: String, + headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + thumbnailContent: @Composable () -> Unit, +) { + val (colorPalette, typography) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + val songs by produceSaveableState( + initialValue = emptyList(), + stateSaver = DetailedSongListSaver + ) { + Database + .albumSongs(browseId) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val thumbnailSizeDp = Dimensions.thumbnails.song + + val lazyListState = rememberLazyListState() + + LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + headerContent { + SecondaryTextButton( + text = "Enqueue", + enabled = songs.isNotEmpty(), + onClick = { + binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem)) + } + ) + } + + if (!isLandscape) { + thumbnailContent() + } + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id } + ) { index, song -> + SongItem( + title = song.title, + authors = song.artistsText, + duration = song.durationText, + thumbnailSizeDp = thumbnailSizeDp, + thumbnailContent = { + BasicText( + text = "${index + 1}", + style = typography.s.semiBold.center.color(colorPalette.textDisabled), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(thumbnailSizeDp) + .align(Alignment.Center) + ) + }, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem, + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + songs.map(DetailedSong::asMediaItem), + index + ) + } + ) + ) + } + + if (songs.isEmpty()) { + item(key = "loading") { + ShimmerHost( + modifier = Modifier + .fillParentMaxSize() + ) { + repeat(4) { + SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) + } + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + iconId = R.drawable.shuffle, + onClick = { + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(DetailedSong::asMediaItem) + ) + } + } + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongs.kt new file mode 100644 index 0000000..517d4d0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongs.kt @@ -0,0 +1,155 @@ +package it.vfsfitvnm.vimusic.ui.screens.artist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver +import it.vfsfitvnm.vimusic.savers.nullableSaver +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.ShimmerHost +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.enqueue +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun ArtistLocalSongs( + browseId: String, + headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + thumbnailContent: @Composable () -> Unit, +) { + val binder = LocalPlayerServiceBinder.current + val (colorPalette) = LocalAppearance.current + val menuState = LocalMenuState.current + + val songs by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(DetailedSongListSaver) + ) { + Database + .artistSongs(browseId) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val songThumbnailSizeDp = Dimensions.thumbnails.song + val songThumbnailSizePx = songThumbnailSizeDp.px + + val lazyListState = rememberLazyListState() + + LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column { + headerContent { + SecondaryTextButton( + text = "Enqueue", + enabled = !songs.isNullOrEmpty(), + onClick = { + binder?.player?.enqueue(songs!!.map(DetailedSong::asMediaItem)) + } + ) + } + + thumbnailContent() + } + } + + songs?.let { songs -> + itemsIndexed( + items = songs, + key = { _, song -> song.id } + ) { index, song -> + SongItem( + song = song, + thumbnailSizeDp = songThumbnailSizeDp, + thumbnailSizePx = songThumbnailSizePx, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem, + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + songs.map(DetailedSong::asMediaItem), + index + ) + } + ) + ) + } + } ?: item(key = "loading") { + ShimmerHost { + repeat(4) { + SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + iconId = R.drawable.shuffle, + onClick = { + songs?.let { songs -> + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(DetailedSong::asMediaItem) + ) + } + } + } + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt new file mode 100644 index 0000000..7be6013 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt @@ -0,0 +1,291 @@ +package it.vfsfitvnm.vimusic.ui.screens.artist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.ShimmerHost +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.items.AlbumItem +import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.forcePlay +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun ArtistOverview( + youtubeArtistPage: Innertube.ArtistPage?, + onViewAllSongsClick: () -> Unit, + onViewAllAlbumsClick: () -> Unit, + onViewAllSinglesClick: () -> Unit, + onAlbumClick: (String) -> Unit, + thumbnailContent: @Composable () -> Unit, + headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, +) { + val (colorPalette, typography) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val songThumbnailSizeDp = Dimensions.thumbnails.song + val songThumbnailSizePx = songThumbnailSizeDp.px + val albumThumbnailSizeDp = 108.dp + val albumThumbnailSizePx = albumThumbnailSizeDp.px + + val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() + + val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + + val scrollState = rememberScrollState() + + LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + Box { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(scrollState) + .padding(windowInsets.only(WindowInsetsSides.Vertical).asPaddingValues()) + ) { + Box( + modifier = Modifier + .padding(endPaddingValues) + ) { + headerContent { + youtubeArtistPage?.shuffleEndpoint?.let { endpoint -> + SecondaryTextButton( + text = "Shuffle", + onClick = { + binder?.stopRadio() + binder?.playRadio(endpoint) + } + ) + } + } + } + + thumbnailContent() + + if (youtubeArtistPage != null) { + youtubeArtistPage.songs?.let { songs -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(endPaddingValues) + ) { + BasicText( + text = "Songs", + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + youtubeArtistPage.songsEndpoint?.let { + BasicText( + text = "View all", + style = typography.xs.secondary, + modifier = sectionTextModifier + .clickable(onClick = onViewAllSongsClick), + ) + } + } + + songs.forEach { song -> + SongItem( + song = song, + thumbnailSizeDp = songThumbnailSizeDp, + thumbnailSizePx = songThumbnailSizePx, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem, + ) + } + }, + onClick = { + val mediaItem = song.asMediaItem + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) + ) + } + ) + .padding(endPaddingValues) + ) + } + } + + youtubeArtistPage.albums?.let { albums -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(endPaddingValues) + ) { + BasicText( + text = "Albums", + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + youtubeArtistPage.albumsEndpoint?.let { + BasicText( + text = "View all", + style = typography.xs.secondary, + modifier = sectionTextModifier + .clickable(onClick = onViewAllAlbumsClick), + ) + } + } + + LazyRow( + contentPadding = endPaddingValues, + modifier = Modifier + .fillMaxWidth() + ) { + items( + items = albums, + key = Innertube.AlbumItem::key + ) { album -> + AlbumItem( + album = album, + thumbnailSizePx = albumThumbnailSizePx, + thumbnailSizeDp = albumThumbnailSizeDp, + alternative = true, + modifier = Modifier + .clickable(onClick = { onAlbumClick(album.key) }) + ) + } + } + } + + youtubeArtistPage.singles?.let { singles -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(endPaddingValues) + ) { + BasicText( + text = "Singles", + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + youtubeArtistPage.singlesEndpoint?.let { + BasicText( + text = "View all", + style = typography.xs.secondary, + modifier = sectionTextModifier + .clickable(onClick = onViewAllSinglesClick), + ) + } + } + + LazyRow( + contentPadding = endPaddingValues, + modifier = Modifier + .fillMaxWidth() + ) { + items( + items = singles, + key = Innertube.AlbumItem::key + ) { album -> + AlbumItem( + album = album, + thumbnailSizePx = albumThumbnailSizePx, + thumbnailSizeDp = albumThumbnailSizeDp, + alternative = true, + modifier = Modifier + .clickable(onClick = { onAlbumClick(album.key) }) + ) + } + } + } + } else { + ShimmerHost { + TextPlaceholder(modifier = sectionTextModifier) + + repeat(5) { + SongItemPlaceholder( + thumbnailSizeDp = songThumbnailSizeDp, + ) + } + + repeat(2) { + TextPlaceholder(modifier = sectionTextModifier) + + Row { + repeat(2) { + AlbumItemPlaceholder( + thumbnailSizeDp = albumThumbnailSizeDp, + alternative = true + ) + } + } + } + } + } + } + + youtubeArtistPage?.radioEndpoint?.let { endpoint -> + FloatingActionsContainerWithScrollToTop( + scrollState = scrollState, + iconId = R.drawable.radio, + onClick = { + binder?.stopRadio() + binder?.playRadio(endpoint) + } + ) + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt new file mode 100644 index 0000000..4d60a23 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt @@ -0,0 +1,381 @@ +package it.vfsfitvnm.vimusic.ui.screens.artist + +import android.content.Intent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.Artist +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.savers.ArtistSaver +import it.vfsfitvnm.vimusic.savers.InnertubeAlbumsPageSaver +import it.vfsfitvnm.vimusic.savers.InnertubeArtistPageSaver +import it.vfsfitvnm.vimusic.savers.InnertubeSongsPageSaver +import it.vfsfitvnm.vimusic.savers.nullableSaver +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent +import it.vfsfitvnm.vimusic.ui.items.AlbumItem +import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder +import it.vfsfitvnm.vimusic.ui.screens.albumRoute +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes +import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.artistScreenTabIndexKey +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.forcePlay +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.rememberPreference +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody +import it.vfsfitvnm.youtubemusic.requests.artistPage +import it.vfsfitvnm.youtubemusic.requests.itemsPage +import it.vfsfitvnm.youtubemusic.utils.from +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun ArtistScreen(browseId: String) { + val saveableStateHolder = rememberSaveableStateHolder() + + val (tabIndex, onTabIndexChanged) = rememberPreference( + artistScreenTabIndexKey, + defaultValue = 0 + ) + + val artist by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(ArtistSaver), + ) { + Database + .artist(browseId) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val youtubeArtist by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(InnertubeArtistPageSaver), + tabIndex < 4 + ) { + if (value != null || (tabIndex == 4 && withContext(Dispatchers.IO) { + Database.artistTimestamp( + browseId + ) + } != null)) return@produceSaveableState + + withContext(Dispatchers.IO) { + Innertube.artistPage(BrowseBody(browseId = browseId)) + }?.onSuccess { artistPage -> + value = artistPage + + query { + Database.upsert( + Artist( + id = browseId, + name = artistPage.name, + thumbnailUrl = artistPage.thumbnail?.url, + info = artistPage.description, + timestamp = System.currentTimeMillis(), + bookmarkedAt = artist?.bookmarkedAt + ) + ) + } + } + } + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + val thumbnailContent = + adaptiveThumbnailContent( + artist?.timestamp == null, + artist?.thumbnailUrl, + CircleShape + ) + + val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = + { textButton -> + if (artist?.timestamp == null) { + HeaderPlaceholder( + modifier = Modifier + .shimmer() + ) + } else { + val (colorPalette) = LocalAppearance.current + val context = LocalContext.current + + Header(title = artist?.name ?: "Unknown") { + textButton?.invoke() + + Spacer( + modifier = Modifier + .weight(1f) + ) + + HeaderIconButton( + icon = if (artist?.bookmarkedAt == null) { + R.drawable.bookmark_outline + } else { + R.drawable.bookmark + }, + color = colorPalette.accent, + onClick = { + val bookmarkedAt = + if (artist?.bookmarkedAt == null) System.currentTimeMillis() else null + + query { + artist + ?.copy(bookmarkedAt = bookmarkedAt) + ?.let(Database::update) + } + } + ) + + HeaderIconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/channel/$browseId" + ) + } + + context.startActivity(Intent.createChooser(sendIntent, null)) + } + ) + } + } + } + + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChanged = onTabIndexChanged, + tabColumnContent = { Item -> + Item(0, "Overview", R.drawable.sparkles) + Item(1, "Songs", R.drawable.musical_notes) + Item(2, "Albums", R.drawable.disc) + Item(3, "Singles", R.drawable.disc) + Item(4, "Library", R.drawable.library) + }, + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> ArtistOverview( + youtubeArtistPage = youtubeArtist, + thumbnailContent = thumbnailContent, + headerContent = headerContent, + onAlbumClick = { albumRoute(it) }, + onViewAllSongsClick = { onTabIndexChanged(1) }, + onViewAllAlbumsClick = { onTabIndexChanged(2) }, + onViewAllSinglesClick = { onTabIndexChanged(3) }, + ) + + 1 -> { + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = InnertubeSongsPageSaver, + headerContent = headerContent, + itemsPageProvider = youtubeArtist?.let { + ({ continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicResponsiveListItemRenderer = Innertube.SongItem::from, + ) + } ?: youtubeArtist + ?.songsEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> + Innertube.itemsPage( + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params, + ), + fromMusicResponsiveListItemRenderer = Innertube.SongItem::from, + ) + } + ?: Result.success( + Innertube.ItemsPage( + items = youtubeArtist?.songs, + continuation = null + ) + ) + }) + }, + itemContent = { song -> + SongItem( + song = song, + thumbnailSizeDp = thumbnailSizeDp, + thumbnailSizePx = thumbnailSizePx, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem, + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(song.asMediaItem) + binder?.setupRadio(song.info?.endpoint) + } + ) + ) + }, + itemPlaceholderContent = { + SongItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + + 2 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = InnertubeAlbumsPageSaver, + headerContent = headerContent, + emptyItemsText = "This artist didn't release any album", + itemsPageProvider = youtubeArtist?.let { + ({ continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, + ) + } ?: youtubeArtist + ?.albumsEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> + Innertube.itemsPage( + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params, + ), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, + ) + } + ?: Result.success( + Innertube.ItemsPage( + items = youtubeArtist?.albums, + continuation = null + ) + ) + }) + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable(onClick = { albumRoute(album.key) }) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + + 3 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = InnertubeAlbumsPageSaver, + headerContent = headerContent, + emptyItemsText = "This artist didn't release any single", + itemsPageProvider = youtubeArtist?.let { + ({ continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, + ) + } ?: youtubeArtist + ?.singlesEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> + Innertube.itemsPage( + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params, + ), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, + ) + } + ?: Result.success( + Innertube.ItemsPage( + items = youtubeArtist?.singles, + continuation = null + ) + ) + }) + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable(onClick = { albumRoute(album.key) }) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + + 4 -> ArtistLocalSongs( + browseId = browseId, + headerContent = headerContent, + thumbnailContent = thumbnailContent, + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt new file mode 100644 index 0000000..42424ad --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt @@ -0,0 +1,51 @@ +package it.vfsfitvnm.vimusic.ui.screens.builtinplaylist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun BuiltInPlaylistScreen(builtInPlaylist: BuiltInPlaylist) { + val saveableStateHolder = rememberSaveableStateHolder() + + val (tabIndex, onTabIndexChanged) = rememberSaveable { + mutableStateOf(when (builtInPlaylist) { + BuiltInPlaylist.Favorites -> 0 + BuiltInPlaylist.Offline -> 1 + }) + } + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChanged = onTabIndexChanged, + tabColumnContent = { Item -> + Item(0, "Favorites", R.drawable.heart) + Item(1, "Offline", R.drawable.airplane) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Favorites) + 1 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Offline) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt new file mode 100644 index 0000000..98543c8 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt @@ -0,0 +1,169 @@ +package it.vfsfitvnm.vimusic.ui.screens.builtinplaylist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.enqueue +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + val songs by produceSaveableState( + initialValue = emptyList(), + stateSaver = DetailedSongListSaver + ) { + when (builtInPlaylist) { + BuiltInPlaylist.Favorites -> Database + .favorites() + .flowOn(Dispatchers.IO) + BuiltInPlaylist.Offline -> Database + .songsWithContentLength() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { song -> + song.contentLength?.let { + binder?.cache?.isCached(song.id, 0, song.contentLength) + } ?: false + } + } + }.collect { value = it } + } + + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSize = thumbnailSizeDp.px + + val lazyListState = rememberLazyListState() + + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header( + title = when (builtInPlaylist) { + BuiltInPlaylist.Favorites -> "Favorites" + BuiltInPlaylist.Offline -> "Offline" + }, + modifier = Modifier + .padding(bottom = 8.dp) + ) { + SecondaryTextButton( + text = "Enqueue", + enabled = songs.isNotEmpty(), + onClick = { + binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem)) + } + ) + + Spacer( + modifier = Modifier + .weight(1f) + ) + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id }, + contentType = { _, song -> song }, + ) { index, song -> + SongItem( + song = song, + thumbnailSizeDp = thumbnailSizeDp, + thumbnailSizePx = thumbnailSize, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + when (builtInPlaylist) { + BuiltInPlaylist.Favorites -> NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = menuState::hide + ) + + BuiltInPlaylist.Offline -> InHistoryMediaItemMenu( + song = song, + onDismiss = menuState::hide + ) + } + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + songs.map(DetailedSong::asMediaItem), + index + ) + } + ) + .animateItemPlacement() + ) + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + iconId = R.drawable.shuffle, + onClick = { + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(DetailedSong::asMediaItem) + ) + } + } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeAlbums.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeAlbums.kt new file mode 100644 index 0000000..7c7e17f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeAlbums.kt @@ -0,0 +1,148 @@ +package it.vfsfitvnm.vimusic.ui.screens.home + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.AlbumSortBy +import it.vfsfitvnm.vimusic.enums.SortOrder +import it.vfsfitvnm.vimusic.models.Album +import it.vfsfitvnm.vimusic.savers.AlbumListSaver +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton +import it.vfsfitvnm.vimusic.ui.items.AlbumItem +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.albumSortByKey +import it.vfsfitvnm.vimusic.utils.albumSortOrderKey +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun HomeAlbums( + onAlbumClick: (Album) -> Unit, + onSearchClick: () -> Unit, +) { + val (colorPalette) = LocalAppearance.current + + var sortBy by rememberPreference(albumSortByKey, AlbumSortBy.DateAdded) + var sortOrder by rememberPreference(albumSortOrderKey, SortOrder.Descending) + + val items by produceSaveableState( + initialValue = emptyList(), + stateSaver = AlbumListSaver, + sortBy, sortOrder, + ) { + Database + .albums(sortBy, sortOrder) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val thumbnailSizeDp = Dimensions.thumbnails.song * 2 + val thumbnailSizePx = thumbnailSizeDp.px + + val sortOrderIconRotation by animateFloatAsState( + targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing) + ) + + val lazyListState = rememberLazyListState() + + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header(title = "Albums") { + HeaderIconButton( + icon = R.drawable.calendar, + color = if (sortBy == AlbumSortBy.Year) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = AlbumSortBy.Year } + ) + + HeaderIconButton( + icon = R.drawable.text, + color = if (sortBy == AlbumSortBy.Title) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = AlbumSortBy.Title } + ) + + HeaderIconButton( + icon = R.drawable.time, + color = if (sortBy == AlbumSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = AlbumSortBy.DateAdded } + ) + + Spacer( + modifier = Modifier + .width(2.dp) + ) + + HeaderIconButton( + icon = R.drawable.arrow_up, + color = colorPalette.text, + onClick = { sortOrder = !sortOrder }, + modifier = Modifier + .graphicsLayer { rotationZ = sortOrderIconRotation } + ) + } + } + + items( + items = items, + key = Album::id + ) { album -> + AlbumItem( + album = album, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable(onClick = { onAlbumClick(album) }) + .animateItemPlacement() + ) + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + iconId = R.drawable.search, + onClick = onSearchClick + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtists.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtists.kt new file mode 100644 index 0000000..a721eff --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtists.kt @@ -0,0 +1,151 @@ +package it.vfsfitvnm.vimusic.ui.screens.home + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.ArtistSortBy +import it.vfsfitvnm.vimusic.enums.SortOrder +import it.vfsfitvnm.vimusic.models.Artist +import it.vfsfitvnm.vimusic.savers.ArtistListSaver +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton +import it.vfsfitvnm.vimusic.ui.items.ArtistItem +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.artistSortByKey +import it.vfsfitvnm.vimusic.utils.artistSortOrderKey +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun HomeArtistList( + onArtistClick: (Artist) -> Unit, + onSearchClick: () -> Unit, +) { + val (colorPalette) = LocalAppearance.current + + var sortBy by rememberPreference(artistSortByKey, ArtistSortBy.DateAdded) + var sortOrder by rememberPreference(artistSortOrderKey, SortOrder.Descending) + + val items by produceSaveableState( + initialValue = emptyList(), + stateSaver = ArtistListSaver, + sortBy, sortOrder, + ) { + Database + .artists(sortBy, sortOrder) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val thumbnailSizeDp = Dimensions.thumbnails.song * 2 + val thumbnailSizePx = thumbnailSizeDp.px + + val sortOrderIconRotation by animateFloatAsState( + targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing) + ) + + val lazyGridState = rememberLazyGridState() + + Box { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2), + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2), + horizontalArrangement = Arrangement.spacedBy( + space = Dimensions.itemsVerticalPadding * 2, + alignment = Alignment.CenterHorizontally + ), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0, + span = { GridItemSpan(maxLineSpan) } + ) { + Header(title = "Artists") { + HeaderIconButton( + icon = R.drawable.text, + color = if (sortBy == ArtistSortBy.Name) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = ArtistSortBy.Name } + ) + + HeaderIconButton( + icon = R.drawable.time, + color = if (sortBy == ArtistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = ArtistSortBy.DateAdded } + ) + + Spacer( + modifier = Modifier + .width(2.dp) + ) + + HeaderIconButton( + icon = R.drawable.arrow_up, + color = colorPalette.text, + onClick = { sortOrder = !sortOrder }, + modifier = Modifier + .graphicsLayer { rotationZ = sortOrderIconRotation } + ) + } + } + + items(items = items, key = Artist::id) { artist -> + ArtistItem( + artist = artist, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + alternative = true, + modifier = Modifier + .clickable(onClick = { onArtistClick(artist) }) + .animateItemPlacement() + ) + } + } + + FloatingActionsContainerWithScrollToTop( + lazyGridState = lazyGridState, + iconId = R.drawable.search, + onClick = onSearchClick + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomePlaylists.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomePlaylists.kt new file mode 100644 index 0000000..387b724 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomePlaylists.kt @@ -0,0 +1,216 @@ +package it.vfsfitvnm.vimusic.ui.screens.home + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist +import it.vfsfitvnm.vimusic.enums.PlaylistSortBy +import it.vfsfitvnm.vimusic.enums.SortOrder +import it.vfsfitvnm.vimusic.models.Playlist +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.savers.PlaylistPreviewListSaver +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog +import it.vfsfitvnm.vimusic.ui.items.PlaylistItem +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.playlistSortByKey +import it.vfsfitvnm.vimusic.utils.playlistSortOrderKey +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@Composable +fun HomePlaylists( + onBuiltInPlaylist: (BuiltInPlaylist) -> Unit, + onPlaylistClick: (Playlist) -> Unit, + onSearchClick: () -> Unit, +) { + val (colorPalette) = LocalAppearance.current + + var isCreatingANewPlaylist by rememberSaveable { + mutableStateOf(false) + } + + if (isCreatingANewPlaylist) { + TextFieldDialog( + hintText = "Enter the playlist name", + onDismiss = { + isCreatingANewPlaylist = false + }, + onDone = { text -> + query { + Database.insert(Playlist(name = text)) + } + } + ) + } + + var sortBy by rememberPreference(playlistSortByKey, PlaylistSortBy.DateAdded) + var sortOrder by rememberPreference(playlistSortOrderKey, SortOrder.Descending) + + val items by produceSaveableState( + initialValue = emptyList(), + stateSaver = PlaylistPreviewListSaver, + sortBy, sortOrder, + ) { + Database + .playlistPreviews(sortBy, sortOrder) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val sortOrderIconRotation by animateFloatAsState( + targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing) + ) + + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px + + val lazyGridState = rememberLazyGridState() + + Box { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2), + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2), + horizontalArrangement = Arrangement.spacedBy( + space = Dimensions.itemsVerticalPadding * 2, + alignment = Alignment.CenterHorizontally + ), + modifier = Modifier + .fillMaxSize() + .background(colorPalette.background0) + ) { + item(key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) }) { + Header(title = "Playlists") { + SecondaryTextButton( + text = "New playlist", + onClick = { isCreatingANewPlaylist = true } + ) + + Spacer( + modifier = Modifier + .weight(1f) + ) + + HeaderIconButton( + icon = R.drawable.medical, + color = if (sortBy == PlaylistSortBy.SongCount) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = PlaylistSortBy.SongCount } + ) + + HeaderIconButton( + icon = R.drawable.text, + color = if (sortBy == PlaylistSortBy.Name) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = PlaylistSortBy.Name } + ) + + HeaderIconButton( + icon = R.drawable.time, + color = if (sortBy == PlaylistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = PlaylistSortBy.DateAdded } + ) + + Spacer( + modifier = Modifier + .width(2.dp) + ) + + HeaderIconButton( + icon = R.drawable.arrow_up, + color = colorPalette.text, + onClick = { sortOrder = !sortOrder }, + modifier = Modifier + .graphicsLayer { rotationZ = sortOrderIconRotation } + ) + } + } + + item(key = "favorites") { + PlaylistItem( + icon = R.drawable.heart, + colorTint = colorPalette.red, + name = "Favorites", + songCount = null, + thumbnailSizeDp = thumbnailSizeDp, + alternative = true, + modifier = Modifier + .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) }) + .animateItemPlacement() + ) + } + + item(key = "offline") { + PlaylistItem( + icon = R.drawable.airplane, + colorTint = colorPalette.blue, + name = "Offline", + songCount = null, + thumbnailSizeDp = thumbnailSizeDp, + alternative = true, + modifier = Modifier + .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) }) + .animateItemPlacement() + ) + } + + items(items = items, key = { it.playlist.id }) { playlistPreview -> + PlaylistItem( + playlist = playlistPreview, + thumbnailSizeDp = thumbnailSizeDp, + thumbnailSizePx = thumbnailSizePx, + alternative = true, + modifier = Modifier + .clickable(onClick = { onPlaylistClick(playlistPreview.playlist) }) + .animateItemPlacement() + ) + } + } + + FloatingActionsContainerWithScrollToTop( + lazyGridState = lazyGridState, + iconId = R.drawable.search, + onClick = onSearchClick + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..1e677ab --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt @@ -0,0 +1,147 @@ +package it.vfsfitvnm.vimusic.ui.screens.home + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.route.defaultStacking +import it.vfsfitvnm.route.defaultStill +import it.vfsfitvnm.route.defaultUnstacking +import it.vfsfitvnm.route.isStacking +import it.vfsfitvnm.route.isUnknown +import it.vfsfitvnm.route.isUnstacking +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.SearchQuery +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.albumRoute +import it.vfsfitvnm.vimusic.ui.screens.artistRoute +import it.vfsfitvnm.vimusic.ui.screens.builtInPlaylistRoute +import it.vfsfitvnm.vimusic.ui.screens.builtinplaylist.BuiltInPlaylistScreen +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes +import it.vfsfitvnm.vimusic.ui.screens.localPlaylistRoute +import it.vfsfitvnm.vimusic.ui.screens.localplaylist.LocalPlaylistScreen +import it.vfsfitvnm.vimusic.ui.screens.playlistRoute +import it.vfsfitvnm.vimusic.ui.screens.search.SearchScreen +import it.vfsfitvnm.vimusic.ui.screens.searchResultRoute +import it.vfsfitvnm.vimusic.ui.screens.searchRoute +import it.vfsfitvnm.vimusic.ui.screens.searchresult.SearchResultScreen +import it.vfsfitvnm.vimusic.ui.screens.settings.SettingsScreen +import it.vfsfitvnm.vimusic.ui.screens.settingsRoute +import it.vfsfitvnm.vimusic.utils.homeScreenTabIndexKey +import it.vfsfitvnm.vimusic.utils.rememberPreference + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun HomeScreen(onPlaylistUrl: (String) -> Unit) { + val saveableStateHolder = rememberSaveableStateHolder() + + RouteHandler( + listenToGlobalEmitter = true, + transitionSpec = { + when { + isStacking -> defaultStacking + isUnstacking -> defaultUnstacking + isUnknown -> when { + initialState.route == searchRoute && targetState.route == searchResultRoute -> defaultStacking + initialState.route == searchResultRoute && targetState.route == searchRoute -> defaultUnstacking + else -> defaultStill + } + else -> defaultStill + } + } + ) { + globalRoutes() + + settingsRoute { + SettingsScreen() + } + + localPlaylistRoute { playlistId -> + LocalPlaylistScreen( + playlistId = playlistId ?: error("playlistId cannot be null") + ) + } + + builtInPlaylistRoute { builtInPlaylist -> + BuiltInPlaylistScreen( + builtInPlaylist = builtInPlaylist + ) + } + + searchResultRoute { query -> + SearchResultScreen( + query = query, + onSearchAgain = { + searchRoute(query) + } + ) + } + + searchRoute { initialTextInput -> + SearchScreen( + initialTextInput = initialTextInput, + onSearch = { query -> + pop() + searchResultRoute(query) + + query { + Database.insert(SearchQuery(query = query)) + } + }, + onViewPlaylist = onPlaylistUrl + ) + } + + host { + val (tabIndex, onTabChanged) = rememberPreference( + homeScreenTabIndexKey, + defaultValue = 0 + ) + + Scaffold( + topIconButtonId = R.drawable.equalizer, + onTopIconButtonClick = { settingsRoute() }, + tabIndex = tabIndex, + onTabChanged = onTabChanged, + tabColumnContent = { Item -> + Item(0, "Quick picks", R.drawable.sparkles) + Item(1, "Songs", R.drawable.musical_notes) + Item(2, "Playlists", R.drawable.playlist) + Item(3, "Artists", R.drawable.person) + Item(4, "Albums", R.drawable.disc) + }, + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> QuickPicks( + onAlbumClick = { albumRoute(it) }, + onArtistClick = { artistRoute(it) }, + onPlaylistClick = { playlistRoute(it) }, + onSearchClick = { searchRoute("") } + ) + 1 -> HomeSongs( + onSearchClick = { searchRoute("") } + ) + 2 -> HomePlaylists( + onBuiltInPlaylist = { builtInPlaylistRoute(it) }, + onPlaylistClick = { localPlaylistRoute(it.id) }, + onSearchClick = { searchRoute("") } + ) + 3 -> HomeArtistList( + onArtistClick = { artistRoute(it.id) }, + onSearchClick = { searchRoute("") } + ) + 4 -> HomeAlbums( + onAlbumClick = { albumRoute(it.id) }, + onSearchClick = { searchRoute("") } + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongs.kt new file mode 100644 index 0000000..8eaa887 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongs.kt @@ -0,0 +1,203 @@ +package it.vfsfitvnm.vimusic.ui.screens.home + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.SongSortBy +import it.vfsfitvnm.vimusic.enums.SortOrder +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton +import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.onOverlay +import it.vfsfitvnm.vimusic.ui.styling.overlay +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.rememberPreference +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.vimusic.utils.songSortByKey +import it.vfsfitvnm.vimusic.utils.songSortOrderKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun HomeSongs( + onSearchClick: () -> Unit +) { + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px + + var sortBy by rememberPreference(songSortByKey, SongSortBy.DateAdded) + var sortOrder by rememberPreference(songSortOrderKey, SortOrder.Descending) + + val items by produceSaveableState( + initialValue = emptyList(), + stateSaver = DetailedSongListSaver, + sortBy, sortOrder, + ) { + Database + .songs(sortBy, sortOrder) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val sortOrderIconRotation by animateFloatAsState( + targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing) + ) + + val lazyListState = rememberLazyListState() + + Box( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + ) { + item( + key = "header", + contentType = 0 + ) { + Header(title = "Songs") { + HeaderIconButton( + icon = R.drawable.trending, + color = if (sortBy == SongSortBy.PlayTime) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = SongSortBy.PlayTime } + ) + + HeaderIconButton( + icon = R.drawable.text, + color = if (sortBy == SongSortBy.Title) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = SongSortBy.Title } + ) + + HeaderIconButton( + icon = R.drawable.time, + color = if (sortBy == SongSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, + onClick = { sortBy = SongSortBy.DateAdded } + ) + + Spacer( + modifier = Modifier + .width(2.dp) + ) + + HeaderIconButton( + icon = R.drawable.arrow_up, + color = colorPalette.text, + onClick = { sortOrder = !sortOrder }, + modifier = Modifier + .graphicsLayer { rotationZ = sortOrderIconRotation } + ) + } + } + + itemsIndexed( + items = items, + key = { _, song -> song.id } + ) { index, song -> + SongItem( + song = song, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + onThumbnailContent = if (sortBy == SongSortBy.PlayTime) ({ + BasicText( + text = song.formattedTotalPlayTime, + style = typography.xxs.semiBold.center.color(colorPalette.onOverlay), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, colorPalette.overlay) + ), + shape = thumbnailShape + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .align(Alignment.BottomCenter) + ) + }) else null, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + InHistoryMediaItemMenu( + song = song, + onDismiss = menuState::hide + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items.map(DetailedSong::asMediaItem), + index + ) + } + ) + .animateItemPlacement() + ) + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + iconId = R.drawable.search, + onClick = onSearchClick + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/QuickPicks.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/QuickPicks.kt new file mode 100644 index 0000000..b0147cc --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/QuickPicks.kt @@ -0,0 +1,371 @@ +package it.vfsfitvnm.vimusic.ui.screens.home + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +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.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.savers.DetailedSongSaver +import it.vfsfitvnm.vimusic.savers.InnertubeRelatedPageSaver +import it.vfsfitvnm.vimusic.savers.nullableSaver +import it.vfsfitvnm.vimusic.savers.resultSaver +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.ShimmerHost +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.items.AlbumItem +import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.ArtistItem +import it.vfsfitvnm.vimusic.ui.items.ArtistItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.PlaylistItem +import it.vfsfitvnm.vimusic.ui.items.PlaylistItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.SnapLayoutInfoProvider +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.forcePlay +import it.vfsfitvnm.vimusic.utils.produceSaveableOneShotState +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint +import it.vfsfitvnm.youtubemusic.models.bodies.NextBody +import it.vfsfitvnm.youtubemusic.requests.relatedPage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun QuickPicks( + onAlbumClick: (String) -> Unit, + onArtistClick: (String) -> Unit, + onPlaylistClick: (String) -> Unit, + onSearchClick: () -> Unit, +) { + val (colorPalette, typography) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val trending by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(DetailedSongSaver), + ) { + Database.trending() + .flowOn(Dispatchers.IO) + .filterNotNull() + .distinctUntilChanged() + .collect { value = it } + } + + val relatedPageResult by produceSaveableOneShotState( + initialValue = null, + stateSaver = resultSaver(nullableSaver(InnertubeRelatedPageSaver)), + trending?.id + ) { + value = Innertube.relatedPage(NextBody(videoId = (trending?.id ?: "J7p4bzqLvCw"))) + } + + val songThumbnailSizeDp = Dimensions.thumbnails.song + val songThumbnailSizePx = songThumbnailSizeDp.px + val albumThumbnailSizeDp = 108.dp + val albumThumbnailSizePx = albumThumbnailSizeDp.px + val artistThumbnailSizeDp = 92.dp + val artistThumbnailSizePx = artistThumbnailSizeDp.px + val playlistThumbnailSizeDp = 108.dp + val playlistThumbnailSizePx = playlistThumbnailSizeDp.px + + val quickPicksLazyGridItemWidthFactor = 0.9f + val quickPicksLazyGridState = rememberLazyGridState() + val snapLayoutInfoProvider = remember(quickPicksLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = quickPicksLazyGridState, + positionInLayout = {layoutSize, itemSize -> + (layoutSize * quickPicksLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) + } + + val scrollState = rememberScrollState() + + val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() + + val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + .padding(endPaddingValues) + + BoxWithConstraints { + val itemInHorizontalGridWidth = maxWidth * quickPicksLazyGridItemWidthFactor + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(scrollState) + .padding(windowInsets.only(WindowInsetsSides.Vertical).asPaddingValues()) + ) { + Header( + title = "Quick picks", + modifier = Modifier + .padding(endPaddingValues) + ) + + relatedPageResult?.getOrNull()?.let { related -> + LazyHorizontalGrid( + state = quickPicksLazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + contentPadding = endPaddingValues, + modifier = Modifier + .fillMaxWidth() + .height((songThumbnailSizeDp + Dimensions.itemsVerticalPadding * 2) * 4) + ) { + trending?.let { song -> + item { + SongItem( + song = song, + thumbnailSizePx = songThumbnailSizePx, + thumbnailSizeDp = songThumbnailSizeDp, + trailingContent = { + Image( + painter = painterResource(R.drawable.star), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.accent), + modifier = Modifier + .size(16.dp) + ) + }, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem, + ) + } + }, + onClick = { + val mediaItem = song.asMediaItem + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) + ) + } + ) + .animateItemPlacement() + .width(itemInHorizontalGridWidth) + ) + } + } + + items( + items = related.songs?.dropLast(if (trending == null) 0 else 1) ?: emptyList(), + key = Innertube.SongItem::key + ) { song -> + SongItem( + song = song, + thumbnailSizePx = songThumbnailSizePx, + thumbnailSizeDp = songThumbnailSizeDp, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem, + ) + } + }, + onClick = { + val mediaItem = song.asMediaItem + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) + ) + } + ) + .animateItemPlacement() + .width(itemInHorizontalGridWidth) + ) + } + } + + related.albums?.let { albums -> + BasicText( + text = "Related albums", + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + LazyRow(contentPadding = endPaddingValues) { + items( + items = albums, + key = Innertube.AlbumItem::key + ) { album -> + AlbumItem( + album = album, + thumbnailSizePx = albumThumbnailSizePx, + thumbnailSizeDp = albumThumbnailSizeDp, + alternative = true, + modifier = Modifier + .clickable(onClick = { onAlbumClick(album.key) }) + ) + } + } + } + + related.artists?.let { artists -> + BasicText( + text = "Similar artists", + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + LazyRow(contentPadding = endPaddingValues) { + items( + items = artists, + key = Innertube.ArtistItem::key, + ) { artist -> + ArtistItem( + artist = artist, + thumbnailSizePx = artistThumbnailSizePx, + thumbnailSizeDp = artistThumbnailSizeDp, + alternative = true, + modifier = Modifier + .clickable(onClick = { onArtistClick(artist.key) }) + ) + } + } + } + + related.playlists?.let { playlists -> + BasicText( + text = "Playlists you might like", + style = typography.m.semiBold, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + ) + + LazyRow(contentPadding = endPaddingValues) { + items( + items = playlists, + key = Innertube.PlaylistItem::key, + ) { playlist -> + PlaylistItem( + playlist = playlist, + thumbnailSizePx = playlistThumbnailSizePx, + thumbnailSizeDp = playlistThumbnailSizeDp, + alternative = true, + modifier = Modifier + .clickable(onClick = { onPlaylistClick(playlist.key) }) + ) + } + } + } + } ?: relatedPageResult?.exceptionOrNull()?.let { + BasicText( + text = "An error has occurred", + style = typography.s.secondary.center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + ) + } ?: ShimmerHost { + repeat(4) { + SongItemPlaceholder( + thumbnailSizeDp = songThumbnailSizeDp, + ) + } + + TextPlaceholder(modifier = sectionTextModifier) + + Row { + repeat(2) { + AlbumItemPlaceholder( + thumbnailSizeDp = albumThumbnailSizeDp, + alternative = true + ) + } + } + + TextPlaceholder(modifier = sectionTextModifier) + + Row { + repeat(2) { + ArtistItemPlaceholder( + thumbnailSizeDp = albumThumbnailSizeDp, + alternative = true + ) + } + } + + TextPlaceholder(modifier = sectionTextModifier) + + Row { + repeat(2) { + PlaylistItemPlaceholder( + thumbnailSizeDp = albumThumbnailSizeDp, + alternative = true + ) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + scrollState = scrollState, + iconId = R.drawable.search, + onClick = onSearchClick + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistScreen.kt new file mode 100644 index 0000000..7594526 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistScreen.kt @@ -0,0 +1,40 @@ +package it.vfsfitvnm.vimusic.ui.screens.localplaylist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun LocalPlaylistScreen(playlistId: Long) { + val saveableStateHolder = rememberSaveableStateHolder() + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChanged = { }, + tabColumnContent = { Item -> + Item(0, "Songs", R.drawable.musical_notes) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + LocalPlaylistSongs( + playlistId = playlistId, + onDelete = pop + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongs.kt new file mode 100644 index 0000000..3b1287e --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongs.kt @@ -0,0 +1,302 @@ +package it.vfsfitvnm.vimusic.ui.screens.localplaylist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.reordering.ReorderingLazyColumn +import it.vfsfitvnm.reordering.animateItemPlacement +import it.vfsfitvnm.reordering.draggedItem +import it.vfsfitvnm.reordering.rememberReorderingState +import it.vfsfitvnm.reordering.reorder +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.models.SongPlaylistMap +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.savers.PlaylistWithSongsSaver +import it.vfsfitvnm.vimusic.savers.nullableSaver +import it.vfsfitvnm.vimusic.transaction +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton +import it.vfsfitvnm.vimusic.ui.components.themed.IconButton +import it.vfsfitvnm.vimusic.ui.components.themed.InPlaylistMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.Menu +import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.completed +import it.vfsfitvnm.vimusic.utils.enqueue +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.requests.playlistPage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@Composable +fun LocalPlaylistSongs( + playlistId: Long, + onDelete: () -> Unit, +) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + val playlistWithSongs by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(PlaylistWithSongsSaver) + ) { + Database + .playlistWithSongs(playlistId) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val lazyListState = rememberLazyListState() + + val reorderingState = rememberReorderingState( + lazyListState = lazyListState, + key = playlistWithSongs?.songs ?: emptyList(), + onDragEnd = { fromIndex, toIndex -> + query { + Database.move(playlistId, fromIndex, toIndex) + } + }, + extraItemCount = 1 + ) + + var isRenaming by rememberSaveable { + mutableStateOf(false) + } + + if (isRenaming) { + TextFieldDialog( + hintText = "Enter the playlist name", + initialTextInput = playlistWithSongs?.playlist?.name ?: "", + onDismiss = { isRenaming = false }, + onDone = { text -> + query { + playlistWithSongs?.playlist?.copy(name = text)?.let(Database::update) + } + } + ) + } + + var isDeleting by rememberSaveable { + mutableStateOf(false) + } + + if (isDeleting) { + ConfirmationDialog( + text = "Do you really want to delete this playlist?", + onDismiss = { isDeleting = false }, + onConfirm = { + query { + playlistWithSongs?.playlist?.let(Database::delete) + } + onDelete() + } + ) + } + + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px + + val rippleIndication = rememberRipple(bounded = false) + + Box { + ReorderingLazyColumn( + reorderingState = reorderingState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header( + title = playlistWithSongs?.playlist?.name ?: "Unknown", + modifier = Modifier + .padding(bottom = 8.dp) + ) { + SecondaryTextButton( + text = "Enqueue", + enabled = playlistWithSongs?.songs?.isNotEmpty() == true, + onClick = { + playlistWithSongs?.songs + ?.map(DetailedSong::asMediaItem) + ?.let { mediaItems -> + binder?.player?.enqueue(mediaItems) + } + } + ) + + Spacer( + modifier = Modifier + .weight(1f) + ) + + HeaderIconButton( + icon = R.drawable.ellipsis_horizontal, + color = colorPalette.text, + onClick = { + menuState.display { + Menu { + playlistWithSongs?.playlist?.browseId?.let { browseId -> + MenuEntry( + icon = R.drawable.sync, + text = "Sync", + onClick = { + menuState.hide() + transaction { + runBlocking(Dispatchers.IO) { + withContext(Dispatchers.IO) { + Innertube.playlistPage(BrowseBody(browseId = browseId)) + ?.completed() + } + }?.getOrNull()?.let { remotePlaylist -> + Database.clearPlaylist(playlistId) + + remotePlaylist.songsPage + ?.items + ?.map(Innertube.SongItem::asMediaItem) + ?.onEach(Database::insert) + ?.mapIndexed { position, mediaItem -> + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = position + ) + }?.let(Database::insertSongPlaylistMaps) + } + } + } + ) + } + + MenuEntry( + icon = R.drawable.pencil, + text = "Rename", + onClick = { + menuState.hide() + isRenaming = true + } + ) + + MenuEntry( + icon = R.drawable.trash, + text = "Delete", + onClick = { + menuState.hide() + isDeleting = true + } + ) + } + } + } + ) + } + } + + itemsIndexed( + items = playlistWithSongs?.songs ?: emptyList(), + key = { _, song -> song.id }, + contentType = { _, song -> song }, + ) { index, song -> + SongItem( + song = song, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + trailingContent = { + IconButton( + icon = R.drawable.reorder, + color = colorPalette.textDisabled, + indication = rippleIndication, + onClick = {}, + modifier = Modifier + .reorder(reorderingState = reorderingState, index = index) + .size(18.dp) + ) + }, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + InPlaylistMediaItemMenu( + playlistId = playlistId, + positionInPlaylist = index, + song = song, + onDismiss = menuState::hide + ) + } + }, + onClick = { + playlistWithSongs?.songs + ?.map(DetailedSong::asMediaItem) + ?.let { mediaItems -> + binder?.stopRadio() + binder?.player?.forcePlayAtIndex(mediaItems, index) + } + } + ) + .animateItemPlacement(reorderingState = reorderingState) + .draggedItem(reorderingState = reorderingState, index = index) + ) + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + iconId = R.drawable.shuffle, + visible = !reorderingState.isDragging, + onClick = { + playlistWithSongs?.songs?.let { songs -> + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(DetailedSong::asMediaItem) + ) + } + } + } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Controls.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Controls.kt similarity index 77% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Controls.kt rename to app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Controls.kt index efd1c89..2495f56 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Controls.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Controls.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.views.player +package it.vfsfitvnm.vimusic.ui.screens.player import android.text.format.DateUtils import androidx.compose.animation.core.LinearEasing @@ -21,10 +21,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.autoSaver import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,16 +41,19 @@ import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.SeekBar +import it.vfsfitvnm.vimusic.ui.components.themed.IconButton import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.favoritesIcon import it.vfsfitvnm.vimusic.utils.bold import it.vfsfitvnm.vimusic.utils.forceSeekToNext import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious +import it.vfsfitvnm.vimusic.utils.produceSaveableState import it.vfsfitvnm.vimusic.utils.rememberRepeatMode import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn @Composable fun Controls( @@ -73,9 +76,17 @@ fun Controls( mutableStateOf(null) } - val likedAt by remember(mediaId) { - Database.likedAt(mediaId).distinctUntilChanged() - }.collectAsState(initial = null, context = Dispatchers.IO) + val likedAt by produceSaveableState( + initialValue = null, + stateSaver = autoSaver(), + mediaId + ) { + Database + .likedAt(mediaId) + .flowOn(Dispatchers.IO) + .distinctUntilChanged() + .collect { value = it } + } val shouldBePlayingTransition = updateTransition(shouldBePlaying, label = "shouldBePlaying") @@ -150,7 +161,8 @@ fun Controls( .fillMaxWidth() ) { BasicText( - text = DateUtils.formatElapsedTime((scrubbingPosition ?: position) / 1000), + text = DateUtils.formatElapsedTime((scrubbingPosition ?: position) / 1000) + .removePrefix("0"), style = typography.xxs.semiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -158,7 +170,7 @@ fun Controls( if (duration != C.TIME_UNSET) { BasicText( - text = DateUtils.formatElapsedTime(duration / 1000), + text = DateUtils.formatElapsedTime(duration / 1000).removePrefix("0"), style = typography.xxs.semiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -176,31 +188,35 @@ fun Controls( modifier = Modifier .fillMaxWidth() ) { - Image( - painter = painterResource(if (likedAt == null) R.drawable.heart_outline else R.drawable.heart), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.favoritesIcon), - modifier = Modifier - .clickable { - val currentMediaItem = binder.player.currentMediaItem - query { - if (Database.like(mediaId, if (likedAt == null) System.currentTimeMillis() else null) == 0) { - currentMediaItem?.takeIf { it.mediaId == mediaId }?.let { + IconButton( + icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, + color = colorPalette.favoritesIcon, + onClick = { + val currentMediaItem = binder.player.currentMediaItem + query { + if (Database.like( + mediaId, + if (likedAt == null) System.currentTimeMillis() else null + ) == 0 + ) { + currentMediaItem + ?.takeIf { it.mediaId == mediaId } + ?.let { Database.insert(currentMediaItem, Song::toggleLike) } - } } } + }, + modifier = Modifier .weight(1f) .size(24.dp) ) - Image( - painter = painterResource(R.drawable.play_skip_back), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), + IconButton( + icon = R.drawable.play_skip_back, + color = colorPalette.text, + onClick = binder.player::forceSeekToPrevious, modifier = Modifier - .clickable(onClick = binder.player::forceSeekToPrevious) .weight(1f) .size(24.dp) ) @@ -241,33 +257,29 @@ fun Controls( .width(8.dp) ) - Image( - painter = painterResource(R.drawable.play_skip_forward), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), + IconButton( + icon = R.drawable.play_skip_forward, + color = colorPalette.text, + onClick = binder.player::forceSeekToNext, modifier = Modifier - .clickable(onClick = binder.player::forceSeekToNext) .weight(1f) .size(24.dp) ) - Image( - painter = painterResource(R.drawable.infinite), - contentDescription = null, - colorFilter = ColorFilter.tint( - if (repeatMode == Player.REPEAT_MODE_ONE) { - colorPalette.text - } else { - colorPalette.textDisabled + IconButton( + icon = R.drawable.infinite, + color = if (repeatMode == Player.REPEAT_MODE_ONE) { + colorPalette.text + } else { + colorPalette.textDisabled + }, + onClick = { + binder.player.repeatMode = when (binder.player.repeatMode) { + Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL + else -> Player.REPEAT_MODE_ONE } - ), + }, modifier = Modifier - .clickable { - binder.player.repeatMode = when (binder.player.repeatMode) { - Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL - else -> Player.REPEAT_MODE_ONE - } - } .weight(1f) .size(24.dp) ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Lyrics.kt similarity index 58% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt rename to app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Lyrics.kt index aeb15e4..ff2863c 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Lyrics.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.views.player +package it.vfsfitvnm.vimusic.ui.screens.player import android.app.SearchManager import android.content.Intent @@ -12,8 +12,8 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -25,19 +25,18 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.autoSaver import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -46,13 +45,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.media3.common.C import androidx.media3.common.MediaMetadata -import com.valentinilk.shimmer.shimmer import it.vfsfitvnm.kugou.KuGou import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.ShimmerHost import it.vfsfitvnm.vimusic.ui.components.themed.Menu import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog @@ -66,15 +65,16 @@ import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.isShowingSynchronizedLyricsKey import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.relaunchableEffect +import it.vfsfitvnm.vimusic.utils.produceSaveableState import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.verticalFadingEdge -import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.NextBody +import it.vfsfitvnm.youtubemusic.requests.lyrics import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -87,7 +87,6 @@ fun Lyrics( mediaMetadataProvider: () -> MediaMetadata, durationProvider: () -> Long, onLyricsUpdate: (Boolean, String, String) -> Unit, - nestedScrollConnectionProvider: () -> NestedScrollConnection, modifier: Modifier = Modifier ) { AnimatedVisibility( @@ -101,60 +100,67 @@ fun Lyrics( var isShowingSynchronizedLyrics by rememberPreference(isShowingSynchronizedLyricsKey, false) - var state by remember(mediaId, isShowingSynchronizedLyrics) { - mutableStateOf(LyricsState()) + var isEditing by remember(mediaId, isShowingSynchronizedLyrics) { + mutableStateOf(false) } - val fetchLyrics = relaunchableEffect(mediaId, isShowingSynchronizedLyrics) { + val lyrics by produceSaveableState( + initialValue = ".", + stateSaver = autoSaver(), + mediaId, isShowingSynchronizedLyrics + ) { if (isShowingSynchronizedLyrics) { Database.synchronizedLyrics(mediaId) } else { Database.lyrics(mediaId) - }.distinctUntilChanged().map flowMap@{ lyrics -> - if (lyrics != null) return@flowMap lyrics - - state = state.copy(isLoading = true) - - if (isShowingSynchronizedLyrics) { - val mediaMetadata = mediaMetadataProvider() - var duration = withContext(Dispatchers.Main) { - durationProvider() - } - - while (duration == C.TIME_UNSET) { - delay(100) - duration = withContext(Dispatchers.Main) { - durationProvider() - } - } - - KuGou.lyrics( - artist = mediaMetadata.artist?.toString() ?: "", - title = mediaMetadata.title?.toString() ?: "", - duration = duration / 1000 - )?.map { it?.value } - } else { - YouTube.next(mediaId, null) - ?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() } - }?.map { newLyrics -> - onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "") - state = state.copy(isLoading = false) - return@flowMap newLyrics ?: "" - } - - state = state.copy(isLoading = false) - null - }.flowOn(Dispatchers.IO).collect { state = state.copy(lyrics = it) } + } + .flowOn(Dispatchers.IO) + .distinctUntilChanged() + .collect { value = it } } - if (state.isEditing) { + var isError by remember(lyrics) { + mutableStateOf(false) + } + + LaunchedEffect(lyrics == null) { + if (lyrics != null) return@LaunchedEffect + + if (isShowingSynchronizedLyrics) { + val mediaMetadata = mediaMetadataProvider() + var duration = withContext(Dispatchers.Main) { + durationProvider() + } + + while (duration == C.TIME_UNSET) { + delay(100) + duration = withContext(Dispatchers.Main) { + durationProvider() + } + } + + KuGou.lyrics( + artist = mediaMetadata.artist?.toString() ?: "", + title = mediaMetadata.title?.toString() ?: "", + duration = duration / 1000 + )?.map { it?.value } + } else { + Innertube.lyrics(NextBody(videoId = mediaId)) + }?.onSuccess { newLyrics -> + onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "") + }?.onFailure { + isError = true + } + } + + if (isEditing) { TextFieldDialog( hintText = "Enter the lyrics", - initialTextInput = state.lyrics ?: "", + initialTextInput = lyrics ?: "", singleLine = false, maxLines = 10, isTextInputValid = { true }, - onDismiss = { state = state.copy(isEditing = false) }, + onDismiss = { isEditing = false }, onDone = { query { if (isShowingSynchronizedLyrics) { @@ -162,7 +168,6 @@ fun Lyrics( } else { Database.updateLyrics(mediaId, it) } - } } ) @@ -180,7 +185,7 @@ fun Lyrics( .background(Color.Black.copy(0.8f)) ) { AnimatedVisibility( - visible = !state.isLoading && state.lyrics == null, + visible = isError && lyrics == null, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, modifier = Modifier @@ -197,7 +202,7 @@ fun Lyrics( } AnimatedVisibility( - visible = state.lyrics?.let(String::isEmpty) ?: false, + visible = lyrics?.let(String::isEmpty) ?: false, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, modifier = Modifier @@ -213,91 +218,86 @@ fun Lyrics( ) } - if (state.isLoading) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .shimmer() - ) { - repeat(4) { index -> - TextPlaceholder( - color = colorPalette.onOverlayShimmer, - modifier = Modifier - .alpha(1f - index * 0.05f) - ) - } - } - } else { - state.lyrics?.let { lyrics -> - if (lyrics.isNotEmpty() && lyrics != ".") { - if (isShowingSynchronizedLyrics) { - val density = LocalDensity.current - val player = LocalPlayerServiceBinder.current?.player - ?: return@AnimatedVisibility + lyrics?.let { lyrics -> + if (lyrics.isNotEmpty() && lyrics != ".") { + if (isShowingSynchronizedLyrics) { + val density = LocalDensity.current + val player = LocalPlayerServiceBinder.current?.player + ?: return@AnimatedVisibility - val synchronizedLyrics = remember(lyrics) { - SynchronizedLyrics(KuGou.Lyrics(lyrics).sentences) { - player.currentPosition + 50 - } + val synchronizedLyrics = remember(lyrics) { + SynchronizedLyrics(KuGou.Lyrics(lyrics).sentences) { + player.currentPosition + 50 } + } - val lazyListState = rememberLazyListState( - synchronizedLyrics.index, - with(density) { size.roundToPx() } / 6) + val lazyListState = rememberLazyListState( + synchronizedLyrics.index, + with(density) { size.roundToPx() } / 6) - LaunchedEffect(synchronizedLyrics) { - val center = with(density) { size.roundToPx() } / 6 + LaunchedEffect(synchronizedLyrics) { + val center = with(density) { size.roundToPx() } / 6 - while (isActive) { - delay(50) - if (synchronizedLyrics.update()) { - lazyListState.animateScrollToItem( - synchronizedLyrics.index, - center - ) - } - } - } - - LazyColumn( - state = lazyListState, - userScrollEnabled = false, - contentPadding = PaddingValues(vertical = size / 2), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .verticalFadingEdge() - ) { - itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence -> - BasicText( - text = sentence.second, - style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) PureBlackColorPalette.text else PureBlackColorPalette.textDisabled), - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 32.dp) + while (isActive) { + delay(50) + if (synchronizedLyrics.update()) { + lazyListState.animateScrollToItem( + synchronizedLyrics.index, + center ) } } - } else { - BasicText( - text = lyrics, - style = typography.xs.center.medium.color(PureBlackColorPalette.text), - modifier = Modifier - .nestedScroll(remember { nestedScrollConnectionProvider() }) - .verticalFadingEdge() - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(vertical = size / 4, horizontal = 32.dp) - ) } + + LazyColumn( + state = lazyListState, + userScrollEnabled = false, + contentPadding = PaddingValues(vertical = size / 2), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .verticalFadingEdge() + ) { + itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence -> + BasicText( + text = sentence.second, + style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) PureBlackColorPalette.text else PureBlackColorPalette.textDisabled), + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 32.dp) + ) + } + } + } else { + BasicText( + text = lyrics, + style = typography.xs.center.medium.color(PureBlackColorPalette.text), + modifier = Modifier + .verticalFadingEdge() + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(vertical = size / 4, horizontal = 32.dp) + ) } } + } - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(DefaultDarkColorPalette.text), - modifier = Modifier - .padding(all = 4.dp) - .clickable { + if (lyrics == null && !isError) { + ShimmerHost(horizontalAlignment = Alignment.CenterHorizontally) { + repeat(4) { + TextPlaceholder(color = colorPalette.onOverlayShimmer) + } + } + } + + Image( + painter = painterResource(R.drawable.ellipsis_horizontal), + contentDescription = null, + colorFilter = ColorFilter.tint(DefaultDarkColorPalette.text), + modifier = Modifier + .padding(all = 4.dp) + .clickable( + indication = rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = { menuState.display { Menu { MenuEntry( @@ -316,7 +316,7 @@ fun Lyrics( text = "Edit lyrics", onClick = { menuState.hide() - state = state.copy(isEditing = true) + isEditing = true } ) @@ -352,20 +352,14 @@ fun Lyrics( MenuEntry( icon = R.drawable.download, text = "Fetch lyrics again", + enabled = lyrics != null, onClick = { menuState.hide() - if (state.lyrics == null) { - fetchLyrics() - } else { - query { - if (isShowingSynchronizedLyrics) { - Database.updateSynchronizedLyrics( - mediaId, - null - ) - } else { - Database.updateLyrics(mediaId, null) - } + query { + if (isShowingSynchronizedLyrics) { + Database.updateSynchronizedLyrics(mediaId, null) + } else { + Database.updateLyrics(mediaId, null) } } } @@ -373,17 +367,11 @@ fun Lyrics( } } } - .padding(all = 8.dp) - .size(20.dp) - .align(Alignment.BottomEnd) - ) - } + ) + .padding(all = 8.dp) + .size(20.dp) + .align(Alignment.BottomEnd) + ) } } } - -private data class LyricsState( - val isLoading: Boolean = false, - val isEditing: Boolean = false, - val lyrics: String? = ".", -) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/PlaybackError.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlaybackError.kt similarity index 98% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/PlaybackError.kt rename to app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlaybackError.kt index df9b2a7..a654e67 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/PlaybackError.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlaybackError.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.views.player +package it.vfsfitvnm.vimusic.ui.screens.player import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Player.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Player.kt new file mode 100644 index 0000000..23cbcd3 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Player.kt @@ -0,0 +1,389 @@ +package it.vfsfitvnm.vimusic.ui.screens.player + +import android.content.Intent +import android.media.audiofx.AudioEffect +import android.widget.Toast +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import coil.compose.AsyncImage +import it.vfsfitvnm.route.OnGlobalRoute +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.service.PlayerService +import it.vfsfitvnm.vimusic.ui.components.BottomSheet +import it.vfsfitvnm.vimusic.ui.components.BottomSheetState +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState +import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.IconButton +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.collapsedPlayerProgressBar +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.isLandscape +import it.vfsfitvnm.vimusic.utils.rememberMediaItem +import it.vfsfitvnm.vimusic.utils.rememberPositionAndDuration +import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying +import it.vfsfitvnm.vimusic.utils.seamlessPlay +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.vimusic.utils.thumbnail +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint +import kotlin.math.absoluteValue + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun Player( + layoutState: BottomSheetState, + modifier: Modifier = Modifier, +) { + val menuState = LocalMenuState.current + + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + + binder?.player ?: return + + val nullableMediaItem by rememberMediaItem(binder.player) + + val mediaItem = nullableMediaItem ?: return + + val shouldBePlaying by rememberShouldBePlaying(binder.player) + val positionAndDuration by rememberPositionAndDuration(binder.player) + + val windowInsets = WindowInsets.systemBars + + val horizontalBottomPaddingValues = windowInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues() + + OnGlobalRoute { + layoutState.collapseSoft() + } + + BottomSheet( + state = layoutState, + modifier = modifier, + onDismiss = { + binder.stopRadio() + binder.player.clearMediaItems() + }, + collapsedContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier + .background(colorPalette.background1) + .fillMaxSize() + .padding(horizontalBottomPaddingValues) + .drawBehind { + val progress = + positionAndDuration.first.toFloat() / positionAndDuration.second.absoluteValue + + drawLine( + color = colorPalette.collapsedPlayerProgressBar, + start = Offset(x = 0f, y = 1.dp.toPx()), + end = Offset(x = size.width * progress, y = 1.dp.toPx()), + strokeWidth = 2.dp.toPx() + ) + } + ) { + Spacer( + modifier = Modifier + .width(2.dp) + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .height(Dimensions.collapsedPlayer) + ) { + AsyncImage( + model = mediaItem.mediaMetadata.artworkUri.thumbnail(Dimensions.thumbnails.song.px), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(48.dp) + ) + } + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .height(Dimensions.collapsedPlayer) + .weight(1f) + ) { + BasicText( + text = mediaItem.mediaMetadata.title?.toString() ?: "", + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BasicText( + text = mediaItem.mediaMetadata.artist?.toString() ?: "", + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer( + modifier = Modifier + .width(2.dp) + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(Dimensions.collapsedPlayer) + ) { + IconButton( + icon = if (shouldBePlaying) R.drawable.pause else R.drawable.play, + color = colorPalette.text, + onClick = { + if (shouldBePlaying) { + binder.player.pause() + } else { + if (binder.player.playbackState == Player.STATE_IDLE) { + binder.player.prepare() + } + binder.player.play() + } + }, + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + .size(20.dp) + ) + + IconButton( + icon = R.drawable.play_skip_forward, + color = colorPalette.text, + onClick = binder.player::seekToNext, + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + .size(20.dp) + ) + } + + Spacer( + modifier = Modifier + .width(2.dp) + ) + } + } + ) { + var isShowingLyrics by rememberSaveable { + mutableStateOf(false) + } + + var isShowingStatsForNerds by rememberSaveable { + mutableStateOf(false) + } + + val playerBottomSheetState = rememberBottomSheetState( + 64.dp + horizontalBottomPaddingValues.calculateBottomPadding(), + layoutState.expandedBound + ) + + val containerModifier = Modifier + .background(colorPalette.background1) + .padding( + windowInsets + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + .asPaddingValues() + ) + .padding(bottom = playerBottomSheetState.collapsedBound) + + val thumbnailContent: @Composable (modifier: Modifier) -> Unit = { modifier -> + Thumbnail( + isShowingLyrics = isShowingLyrics, + onShowLyrics = { isShowingLyrics = it }, + isShowingStatsForNerds = isShowingStatsForNerds, + onShowStatsForNerds = { isShowingStatsForNerds = it }, + modifier = modifier + .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) + ) + } + + val controlsContent: @Composable (modifier: Modifier) -> Unit = { modifier -> + Controls( + mediaId = mediaItem.mediaId, + title = mediaItem.mediaMetadata.title?.toString(), + artist = mediaItem.mediaMetadata.artist?.toString(), + shouldBePlaying = shouldBePlaying, + position = positionAndDuration.first, + duration = positionAndDuration.second, + modifier = modifier + ) + } + + if (isLandscape) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = containerModifier + .padding(top = 32.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(0.66f) + .padding(bottom = 16.dp) + ) { + thumbnailContent( + modifier = Modifier + .padding(horizontal = 16.dp) + ) + } + + controlsContent( + modifier = Modifier + .padding(vertical = 8.dp) + .fillMaxHeight() + .weight(1f) + ) + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = containerModifier + .padding(top = 54.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1.25f) + ) { + thumbnailContent( + modifier = Modifier + .padding(horizontal = 32.dp, vertical = 8.dp) + ) + } + + controlsContent( + modifier = Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + .weight(1f) + ) + } + } + + + Queue( + layoutState = playerBottomSheetState, + content = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(horizontal = 8.dp) + .fillMaxHeight() + ) { + IconButton( + icon = R.drawable.ellipsis_horizontal, + color = colorPalette.text, + onClick = { + menuState.display { + PlayerMenu( + onDismiss = menuState::hide, + mediaItem = mediaItem, + binder = binder + ) + } + }, + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + .size(20.dp) + ) + + Spacer( + modifier = Modifier + .width(4.dp) + ) + } + }, + backgroundColorProvider = { colorPalette.background2 }, + modifier = Modifier + .align(Alignment.BottomCenter) + ) + } +} + +@ExperimentalAnimationApi +@Composable +private fun PlayerMenu( + binder: PlayerService.Binder, + mediaItem: MediaItem, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val resultRegistryOwner = LocalActivityResultRegistryOwner.current + + BaseMediaItemMenu( + mediaItem = mediaItem, + onStartRadio = { + binder.stopRadio() + binder.player.seamlessPlay(mediaItem) + binder.setupRadio(NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)) + }, + onGoToEqualizer = { + val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder.player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + + if (intent.resolveActivity(context.packageManager) != null) { + val contract = ActivityResultContracts.StartActivityForResult() + + resultRegistryOwner?.activityResultRegistry + ?.register("", contract) {}?.launch(intent) + } else { + Toast.makeText(context, "No equalizer app found!", Toast.LENGTH_SHORT).show() + } + }, + onShowSleepTimer = {}, + onDismiss = onDismiss + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Queue.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Queue.kt new file mode 100644 index 0000000..19a6e68 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Queue.kt @@ -0,0 +1,303 @@ +package it.vfsfitvnm.vimusic.ui.screens.player + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.reordering.ReorderingLazyColumn +import it.vfsfitvnm.reordering.animateItemPlacement +import it.vfsfitvnm.reordering.draggedItem +import it.vfsfitvnm.reordering.rememberReorderingState +import it.vfsfitvnm.reordering.reorder +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.BottomSheet +import it.vfsfitvnm.vimusic.ui.components.BottomSheetState +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.MusicBars +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.IconButton +import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.onOverlay +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex +import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying +import it.vfsfitvnm.vimusic.utils.rememberWindows +import it.vfsfitvnm.vimusic.utils.shuffleQueue +import it.vfsfitvnm.vimusic.utils.smoothScrollToTop +import kotlinx.coroutines.launch + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun Queue( + backgroundColorProvider: () -> Color, + layoutState: BottomSheetState, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + + val windowInsets = WindowInsets.systemBars + + val horizontalBottomPaddingValues = windowInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues() + + BottomSheet( + state = layoutState, + modifier = modifier, + collapsedContent = { + Box( + modifier = Modifier + .drawBehind { drawRect(backgroundColorProvider()) } + .fillMaxSize() + .padding(horizontalBottomPaddingValues) + ) { + Image( + painter = painterResource(R.drawable.playlist), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .align(Alignment.Center) + .size(18.dp) + ) + + content() + } + } + ) { + val binder = LocalPlayerServiceBinder.current + + binder?.player ?: return@BottomSheet + + val menuState = LocalMenuState.current + + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px + + val mediaItemIndex by rememberMediaItemIndex(binder.player) + val windows by rememberWindows(binder.player) + val shouldBePlaying by rememberShouldBePlaying(binder.player) + + val reorderingState = rememberReorderingState( + lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex), + key = windows, + onDragEnd = binder.player::moveMediaItem, + extraItemCount = 0 + ) + + val rippleIndication = rememberRipple(bounded = false) + + Column { + Box( + modifier = Modifier + .background(colorPalette.background1) + .weight(1f) + ) { + ReorderingLazyColumn( + reorderingState = reorderingState, + contentPadding = windowInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top).asPaddingValues(), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) + + ) { + items( + items = windows, + key = { it.uid.hashCode() } + ) { window -> + val isPlayingThisMediaItem = mediaItemIndex == window.firstPeriodIndex + + SongItem( + song = window.mediaItem, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + onThumbnailContent = { + androidx.compose.animation.AnimatedVisibility( + visible = isPlayingThisMediaItem, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.25f), + shape = thumbnailShape + ) + .size(Dimensions.thumbnails.song) + ) { + if (shouldBePlaying) { + MusicBars( + color = colorPalette.onOverlay, + modifier = Modifier + .height(24.dp) + ) + } else { + Image( + painter = painterResource(R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.onOverlay), + modifier = Modifier + .size(24.dp) + ) + } + } + } + }, + trailingContent = { + IconButton( + icon = R.drawable.reorder, + color = colorPalette.textDisabled, + indication = rippleIndication, + onClick = {}, + modifier = Modifier + .reorder( + reorderingState = reorderingState, + index = window.firstPeriodIndex + ) + .size(18.dp) + ) + }, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + QueuedMediaItemMenu( + mediaItem = window.mediaItem, + indexInQueue = if (isPlayingThisMediaItem) null else window.firstPeriodIndex, + onDismiss = menuState::hide + ) + } + }, + onClick = { + if (isPlayingThisMediaItem) { + if (shouldBePlaying) { + binder.player.pause() + } else { + binder.player.play() + } + } else { + binder.player.playWhenReady = true + binder.player.seekToDefaultPosition(window.firstPeriodIndex) + } + } + ) + .animateItemPlacement(reorderingState = reorderingState) + .draggedItem( + reorderingState = reorderingState, + index = window.firstPeriodIndex + ) + ) + } + + item { + if (binder.isLoadingRadio) { + Column( + modifier = Modifier + .shimmer() + ) { + repeat(3) { index -> + SongItemPlaceholder( + thumbnailSizeDp = Dimensions.thumbnails.song, + modifier = Modifier + .alpha(1f - index * 0.125f) + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) + } + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = reorderingState.lazyListState, + iconId = R.drawable.shuffle, + visible = !reorderingState.isDragging, + windowInsets = windowInsets.only(WindowInsetsSides.Horizontal), + onClick = { + reorderingState.coroutineScope.launch { + reorderingState.lazyListState.smoothScrollToTop() + }.invokeOnCompletion { + binder.player.shuffleQueue() + } + } + ) + } + + + Box( + modifier = Modifier + .clickable(onClick = layoutState::collapseSoft) + .background(colorPalette.background2) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(horizontalBottomPaddingValues) + .height(64.dp) + ) { + BasicText( + text = "${windows.size} songs", + style = typography.xxs.medium, + modifier = Modifier + .background( + color = colorPalette.background1, + shape = RoundedCornerShape(16.dp) + ) + .align(Alignment.CenterStart) + .padding(all = 8.dp) + ) + + Image( + painter = painterResource(R.drawable.chevron_down), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .align(Alignment.Center) + .size(18.dp) + ) + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/StatsForNerds.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/StatsForNerds.kt similarity index 95% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/StatsForNerds.kt rename to app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/StatsForNerds.kt index 2aea50b..5d44780 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/StatsForNerds.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/StatsForNerds.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.views.player +package it.vfsfitvnm.vimusic.ui.screens.player import android.text.format.Formatter import androidx.compose.animation.AnimatedVisibility @@ -7,7 +7,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,7 +14,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState @@ -40,7 +38,9 @@ import it.vfsfitvnm.vimusic.ui.styling.overlay import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.rememberVolume -import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.PlayerBody +import it.vfsfitvnm.youtubemusic.requests.player import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged @@ -190,12 +190,10 @@ fun StatsForNerds( style = typography.xxs.medium.color(colorPalette.onOverlay), modifier = Modifier .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, onClick = { query { runBlocking(Dispatchers.IO) { - YouTube.player(mediaId) + Innertube.player(PlayerBody(videoId = mediaId)) ?.map { response -> response.streamingData?.adaptiveFormats ?.findLast { format -> diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt similarity index 85% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt rename to app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt index 4577241..1d6d9c4 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.views.player +package it.vfsfitvnm.vimusic.ui.screens.player import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope @@ -21,18 +21,18 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness +import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.service.LoginRequiredException import it.vfsfitvnm.vimusic.service.PlayableFormatNotFoundException import it.vfsfitvnm.vimusic.service.UnplayableException import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.rememberError import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex @@ -47,7 +47,6 @@ fun Thumbnail( onShowLyrics: (Boolean) -> Unit, isShowingStatsForNerds: Boolean, onShowStatsForNerds: (Boolean) -> Unit, - nestedScrollConnectionProvider: () -> NestedScrollConnection, modifier: Modifier = Modifier ) { val binder = LocalPlayerServiceBinder.current @@ -99,7 +98,7 @@ fun Thumbnail( Box( modifier = modifier .aspectRatio(1f) - .clip(ThumbnailRoundness.shape) + .clip(LocalAppearance.current.thumbnailShape) .size(thumbnailSizeDp) ) { AsyncImage( @@ -121,19 +120,21 @@ fun Thumbnail( isDisplayed = isShowingLyrics && error == null, onDismiss = { onShowLyrics(false) }, onLyricsUpdate = { areSynchronized, mediaId, lyrics -> - if (areSynchronized) { - if (Database.updateSynchronizedLyrics(mediaId, lyrics) == 0) { - if (mediaId == mediaItem.mediaId) { - Database.insert(mediaItem) { song -> - song.copy(synchronizedLyrics = lyrics) + query { + if (areSynchronized) { + if (Database.updateSynchronizedLyrics(mediaId, lyrics) == 0) { + if (mediaId == mediaItem.mediaId) { + Database.insert(mediaItem) { song -> + song.copy(synchronizedLyrics = lyrics) + } } } - } - } else { - if (Database.updateLyrics(mediaId, lyrics) == 0) { - if (mediaId == mediaItem.mediaId) { - Database.insert(mediaItem) { song -> - song.copy(lyrics = lyrics) + } else { + if (Database.updateLyrics(mediaId, lyrics) == 0) { + if (mediaId == mediaItem.mediaId) { + Database.insert(mediaItem) { song -> + song.copy(lyrics = lyrics) + } } } } @@ -142,7 +143,6 @@ fun Thumbnail( size = thumbnailSizeDp, mediaMetadataProvider = mediaItem::mediaMetadata, durationProvider = player::getDuration, - nestedScrollConnectionProvider = nestedScrollConnectionProvider, ) StatsForNerds( diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt new file mode 100644 index 0000000..407d362 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt @@ -0,0 +1,39 @@ +package it.vfsfitvnm.vimusic.ui.screens.playlist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun PlaylistScreen(browseId: String) { + val saveableStateHolder = rememberSaveableStateHolder() + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChanged = { }, + tabColumnContent = { Item -> + Item(0, "Songs", R.drawable.musical_notes) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> PlaylistSongList(browseId = browseId) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt new file mode 100644 index 0000000..da25da6 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt @@ -0,0 +1,248 @@ +package it.vfsfitvnm.vimusic.ui.screens.playlist + +import android.content.Intent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.Playlist +import it.vfsfitvnm.vimusic.models.SongPlaylistMap +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.savers.InnertubePlaylistOrAlbumPageSaver +import it.vfsfitvnm.vimusic.savers.nullableSaver +import it.vfsfitvnm.vimusic.transaction +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.ShimmerHost +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder +import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog +import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.completed +import it.vfsfitvnm.vimusic.utils.enqueue +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning +import it.vfsfitvnm.vimusic.utils.isLandscape +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.requests.playlistPage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun PlaylistSongList( + browseId: String, +) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val context = LocalContext.current + val menuState = LocalMenuState.current + + val playlistPage by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(InnertubePlaylistOrAlbumPageSaver), + ) { + if (value != null && value?.songsPage?.continuation == null) return@produceSaveableState + + value = withContext(Dispatchers.IO) { + Innertube.playlistPage(BrowseBody(browseId = browseId))?.completed()?.getOrNull() + } + } + + val songThumbnailSizeDp = Dimensions.thumbnails.song + val songThumbnailSizePx = songThumbnailSizeDp.px + + var isImportingPlaylist by rememberSaveable { + mutableStateOf(false) + } + + if (isImportingPlaylist) { + TextFieldDialog( + hintText = "Enter the playlist name", + initialTextInput = playlistPage?.title ?: "", + onDismiss = { isImportingPlaylist = false }, + onDone = { text -> + query { + transaction { + val playlistId = Database.insert(Playlist(name = text, browseId = browseId)) + + playlistPage?.songsPage?.items + ?.map(Innertube.SongItem::asMediaItem) + ?.onEach(Database::insert) + ?.mapIndexed { index, mediaItem -> + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = index + ) + }?.let(Database::insertSongPlaylistMaps) + } + } + } + ) + } + + val headerContent: @Composable () -> Unit = { + if (playlistPage == null) { + HeaderPlaceholder( + modifier = Modifier + .shimmer() + ) + } else { + Header(title = playlistPage?.title ?: "Unknown") { + SecondaryTextButton( + text = "Enqueue", + enabled = playlistPage?.songsPage?.items?.isNotEmpty() == true, + onClick = { + playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems -> + binder?.player?.enqueue(mediaItems) + } + } + ) + + Spacer( + modifier = Modifier + .weight(1f) + ) + + HeaderIconButton( + icon = R.drawable.add, + color = colorPalette.text, + onClick = { isImportingPlaylist = true } + ) + + HeaderIconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + (playlistPage?.url ?: "https://music.youtube.com/playlist?list=${browseId.removePrefix("VL")}").let { url -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + + context.startActivity(Intent.createChooser(sendIntent, null)) + } + } + ) + } + } + } + + val thumbnailContent = adaptiveThumbnailContent(playlistPage == null, playlistPage?.thumbnail?.url) + + val lazyListState = rememberLazyListState() + + LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + headerContent() + if (!isLandscape) thumbnailContent() + } + } + + itemsIndexed(items = playlistPage?.songsPage?.items ?: emptyList()) { index, song -> + SongItem( + song = song, + thumbnailSizePx = songThumbnailSizePx, + thumbnailSizeDp = songThumbnailSizeDp, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem, + ) + } + }, + onClick = { + playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems -> + binder?.stopRadio() + binder?.player?.forcePlayAtIndex(mediaItems, index) + } + } + ) + ) + } + + if (playlistPage == null) { + item(key = "loading") { + ShimmerHost( + modifier = Modifier + .fillParentMaxSize() + ) { + repeat(4) { + SongItemPlaceholder(thumbnailSizeDp = songThumbnailSizeDp) + } + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + iconId = R.drawable.shuffle, + onClick = { + playlistPage?.songsPage?.items?.let { songs -> + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(Innertube.SongItem::asMediaItem) + ) + } + } + } + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LocalSongSearch.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LocalSongSearch.kt new file mode 100644 index 0000000..e42964d --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LocalSongSearch.kt @@ -0,0 +1,146 @@ +package it.vfsfitvnm.vimusic.ui.screens.search + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.align +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.forcePlay +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun LocalSongSearch( + textFieldValue: TextFieldValue, + onTextFieldValueChanged: (TextFieldValue) -> Unit, + decorationBox: @Composable (@Composable () -> Unit) -> Unit +) { + val (colorPalette, typography) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + val items by produceSaveableState( + initialValue = emptyList(), + stateSaver = DetailedSongListSaver, + key1 = textFieldValue.text + ) { + if (textFieldValue.text.length > 1) { + Database + .search("%${textFieldValue.text}%") + .flowOn(Dispatchers.IO) + .collect { value = it } + } + } + + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px + + val lazyListState = rememberLazyListState() + + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header( + titleContent = { + BasicTextField( + value = textFieldValue, + onValueChange = onTextFieldValueChanged, + textStyle = typography.xxl.medium.align(TextAlign.End), + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + cursorBrush = SolidColor(colorPalette.text), + decorationBox = decorationBox + ) + }, + actionsContent = { + if (textFieldValue.text.isNotEmpty()) { + SecondaryTextButton( + text = "Clear", + onClick = { onTextFieldValueChanged(TextFieldValue()) } + ) + } + } + ) + } + + items( + items = items, + key = DetailedSong::id, + ) { song -> + SongItem( + song = song, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + InHistoryMediaItemMenu( + song = song, + onDismiss = menuState::hide + ) + } + }, + onClick = { + val mediaItem = song.asMediaItem + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) + ) + } + ) + .animateItemPlacement() + ) + } + } + + FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearch.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearch.kt new file mode 100644 index 0000000..f9f8ae0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearch.kt @@ -0,0 +1,324 @@ +package it.vfsfitvnm.vimusic.ui.screens.search + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.autoSaver +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.SearchQuery +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.savers.SearchQuerySaver +import it.vfsfitvnm.vimusic.savers.listSaver +import it.vfsfitvnm.vimusic.savers.resultSaver +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.align +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.produceSaveableOneShotState +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.SearchSuggestionsBody +import it.vfsfitvnm.youtubemusic.requests.searchSuggestions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn + +@ExperimentalAnimationApi +@Composable +fun OnlineSearch( + textFieldValue: TextFieldValue, + onTextFieldValueChanged: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + onViewPlaylist: (String) -> Unit, + decorationBox: @Composable (@Composable () -> Unit) -> Unit +) { + val (colorPalette, typography) = LocalAppearance.current + + val history by produceSaveableState( + initialValue = emptyList(), + stateSaver = listSaver(SearchQuerySaver), + key1 = textFieldValue.text + ) { + Database.queries("%${textFieldValue.text}%") + .flowOn(Dispatchers.IO) + .distinctUntilChanged { old, new -> old.size == new.size } + .collect { value = it } + } + + val suggestionsResult by produceSaveableOneShotState( + initialValue = null, + stateSaver = resultSaver(autoSaver?>()), + textFieldValue.text + ) { + if (textFieldValue.text.isNotEmpty()) { + value = Innertube.searchSuggestions(SearchSuggestionsBody(input = textFieldValue.text)) + } + } + + val playlistId = remember(textFieldValue.text) { + val isPlaylistUrl = listOf( + "https://www.youtube.com/playlist?", + "https://music.youtube.com/playlist?", + "https://m.youtube.com/playlist?", + ).any(textFieldValue.text::startsWith) + + if (isPlaylistUrl) textFieldValue.text.toUri().getQueryParameter("list") else null + } + + val rippleIndication = rememberRipple(bounded = false) + val timeIconPainter = painterResource(R.drawable.time) + val closeIconPainter = painterResource(R.drawable.close) + val arrowForwardIconPainter = painterResource(R.drawable.arrow_forward) + + val focusRequester = remember { + FocusRequester() + } + + val lazyListState = rememberLazyListState() + + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header( + titleContent = { + BasicTextField( + value = textFieldValue, + onValueChange = onTextFieldValueChanged, + textStyle = typography.xxl.medium.align(TextAlign.End), + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + if (textFieldValue.text.isNotEmpty()) { + onSearch(textFieldValue.text) + } + } + ), + cursorBrush = SolidColor(colorPalette.text), + decorationBox = decorationBox, + modifier = Modifier + .focusRequester(focusRequester) + ) + }, + actionsContent = { + if (playlistId != null) { + val isAlbum = playlistId.startsWith("OLAK5uy_") + + SecondaryTextButton( + text = "View ${if (isAlbum) "album" else "playlist"}", + onClick = { onViewPlaylist(textFieldValue.text) } + ) + } + + Spacer( + modifier = Modifier + .weight(1f) + ) + + if (textFieldValue.text.isNotEmpty()) { + SecondaryTextButton( + text = "Clear", + onClick = { onTextFieldValueChanged(TextFieldValue()) } + ) + } + } + ) + } + + items( + items = history, + key = SearchQuery::id + ) { searchQuery -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = { onSearch(searchQuery.query) }) + .fillMaxWidth() + .padding(all = 16.dp) + ) { + Spacer( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(20.dp) + .paint( + painter = timeIconPainter, + colorFilter = ColorFilter.tint(colorPalette.textDisabled) + ) + ) + + BasicText( + text = searchQuery.query, + style = typography.s.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + Image( + painter = closeIconPainter, + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textDisabled), + modifier = Modifier + .clickable( + indication = rippleIndication, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + query { + Database.delete(searchQuery) + } + } + ) + .padding(horizontal = 8.dp) + .size(20.dp) + ) + + Image( + painter = arrowForwardIconPainter, + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textDisabled), + modifier = Modifier + .clickable( + indication = rippleIndication, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onTextFieldValueChanged( + TextFieldValue( + text = searchQuery.query, + selection = TextRange(searchQuery.query.length) + ) + ) + } + ) + .rotate(225f) + .padding(horizontal = 8.dp) + .size(22.dp) + ) + } + } + + suggestionsResult?.getOrNull()?.let { suggestions -> + items(items = suggestions) { suggestion -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = { onSearch(suggestion) }) + .fillMaxWidth() + .padding(all = 16.dp) + ) { + Spacer( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(20.dp) + ) + + BasicText( + text = suggestion, + style = typography.s.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + Image( + painter = arrowForwardIconPainter, + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textDisabled), + modifier = Modifier + .clickable( + indication = rippleIndication, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onTextFieldValueChanged( + TextFieldValue( + text = suggestion, + selection = TextRange(suggestion.length) + ) + ) + } + ) + .rotate(225f) + .padding(horizontal = 8.dp) + .size(22.dp) + ) + } + } + } ?: suggestionsResult?.exceptionOrNull()?.let { + item { + Box( + modifier = Modifier + .fillMaxSize() + ) { + BasicText( + text = "An error has occurred.", + style = typography.s.secondary.center, + modifier = Modifier + .align(Alignment.Center) + ) + } + } + } + } + + FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) + } + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt new file mode 100644 index 0000000..3f5e01d --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt @@ -0,0 +1,106 @@ +package it.vfsfitvnm.vimusic.ui.screens.search + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.secondary + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun SearchScreen( + initialTextInput: String, + onSearch: (String) -> Unit, + onViewPlaylist: (String) -> Unit +) { + val saveableStateHolder = rememberSaveableStateHolder() + + val (tabIndex, onTabChanged) = rememberSaveable { + mutableStateOf(0) + } + + val (textFieldValue, onTextFieldValueChanged) = rememberSaveable( + initialTextInput, + stateSaver = TextFieldValue.Saver + ) { + mutableStateOf( + TextFieldValue( + text = initialTextInput, + selection = TextRange(initialTextInput.length) + ) + ) + } + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + val decorationBox: @Composable (@Composable () -> Unit) -> Unit = { innerTextField -> + Box { + AnimatedVisibility( + visible = textFieldValue.text.isEmpty(), + enter = fadeIn(tween(300)), + exit = fadeOut(tween(300)), + modifier = Modifier + .align(Alignment.CenterEnd) + ) { + BasicText( + text = "Enter a name", + maxLines = 1, + style = LocalAppearance.current.typography.xxl.secondary + ) + } + + innerTextField() + } + } + + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChanged = onTabChanged, + tabColumnContent = { Item -> + Item(0, "Online", R.drawable.globe) + Item(1, "Library", R.drawable.library) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(currentTabIndex) { + when (currentTabIndex) { + 0 -> OnlineSearch( + textFieldValue = textFieldValue, + onTextFieldValueChanged = onTextFieldValueChanged, + onSearch = onSearch, + onViewPlaylist = onViewPlaylist, + decorationBox = decorationBox + ) + + 1 -> LocalSongSearch( + textFieldValue = textFieldValue, + onTextFieldValueChanged = onTextFieldValueChanged, + decorationBox = decorationBox + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt new file mode 100644 index 0000000..1a0e553 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt @@ -0,0 +1,130 @@ +package it.vfsfitvnm.vimusic.ui.screens.searchresult + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.savers.nullableSaver +import it.vfsfitvnm.vimusic.ui.components.ShimmerHost +import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.utils.plus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@ExperimentalAnimationApi +@Composable +inline fun ItemsPage( + stateSaver: Saver, List>, + crossinline headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, + noinline itemPlaceholderContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + initialPlaceholderCount: Int = 8, + continuationPlaceholderCount: Int = 3, + emptyItemsText: String = "No items found", + noinline itemsPageProvider: (suspend (String?) -> Result?>?)? = null, +) { + val (_, typography) = LocalAppearance.current + val lazyListState = rememberLazyListState() + val updatedItemsPageProvider by rememberUpdatedState(itemsPageProvider) + + val itemsPage by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(stateSaver), + lazyListState, updatedItemsPageProvider + ) { + val currentItemsPageProvider = updatedItemsPageProvider ?: return@produceSaveableState + + snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } } + .collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + + withContext(Dispatchers.IO) { + currentItemsPageProvider(value?.continuation) + }?.onSuccess { + if (it == null) { + if (value == null) { + value = Innertube.ItemsPage(null, null) + } + } else { + value += it + } + } + } + } + + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = modifier + .fillMaxSize() + ) { + item( + key = "header", + contentType = "header", + ) { + headerContent(null) + } + + items( + items = itemsPage?.items ?: emptyList(), + key = Innertube.Item::key, + itemContent = itemContent + ) + + if (itemsPage != null && itemsPage?.items.isNullOrEmpty()) { + item(key = "empty") { + BasicText( + text = emptyItemsText, + style = typography.xs.secondary.center, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 32.dp) + .fillMaxWidth() + ) + } + } + + if (!(itemsPage != null && itemsPage?.continuation == null)) { + item(key = "loading") { + val isFirstLoad = itemsPage?.items.isNullOrEmpty() + ShimmerHost( + modifier = Modifier + .run { + if (isFirstLoad) fillParentMaxSize() else this + } + ) { + repeat(if (isFirstLoad) initialPlaceholderCount else continuationPlaceholderCount) { + itemPlaceholderContent() + } + } + } + } + } + + FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt new file mode 100644 index 0000000..c554ddd --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt @@ -0,0 +1,321 @@ +package it.vfsfitvnm.vimusic.ui.screens.searchresult + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.savers.InnertubeAlbumsPageSaver +import it.vfsfitvnm.vimusic.savers.InnertubeArtistItemListSaver +import it.vfsfitvnm.vimusic.savers.InnertubePlaylistItemListSaver +import it.vfsfitvnm.vimusic.savers.InnertubeSongsPageSaver +import it.vfsfitvnm.vimusic.savers.InnertubeVideoItemListSaver +import it.vfsfitvnm.vimusic.savers.innertubeItemsPageSaver +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.items.AlbumItem +import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.ArtistItem +import it.vfsfitvnm.vimusic.ui.items.ArtistItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.PlaylistItem +import it.vfsfitvnm.vimusic.ui.items.PlaylistItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.SongItem +import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder +import it.vfsfitvnm.vimusic.ui.items.VideoItem +import it.vfsfitvnm.vimusic.ui.items.VideoItemPlaceholder +import it.vfsfitvnm.vimusic.ui.screens.albumRoute +import it.vfsfitvnm.vimusic.ui.screens.artistRoute +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes +import it.vfsfitvnm.vimusic.ui.screens.playlistRoute +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.forcePlay +import it.vfsfitvnm.vimusic.utils.rememberPreference +import it.vfsfitvnm.vimusic.utils.searchResultScreenTabIndexKey +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody +import it.vfsfitvnm.youtubemusic.models.bodies.SearchBody +import it.vfsfitvnm.youtubemusic.requests.searchPage +import it.vfsfitvnm.youtubemusic.utils.from + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { + val saveableStateHolder = rememberSaveableStateHolder() + val (tabIndex, onTabIndexChanges) = rememberPreference(searchResultScreenTabIndexKey, 0) + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { + Header( + title = query, + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { + onSearchAgain() + } + } + ) + } + + val emptyItemsText = "No results found. Please try a different query or category" + + + + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChanged = onTabIndexChanges, + tabColumnContent = { Item -> + Item(0, "Songs", R.drawable.musical_notes) + Item(1, "Albums", R.drawable.disc) + Item(2, "Artists", R.drawable.person) + Item(3, "Videos", R.drawable.film) + Item(4, "Playlists", R.drawable.playlist) + Item(5, "Featured", R.drawable.playlist) + } + ) { tabIndex -> + saveableStateHolder.SaveableStateProvider(tabIndex) { + when (tabIndex) { + 0 -> { + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = InnertubeSongsPageSaver, + itemsPageProvider = { continuation -> + if (continuation == null) { + Innertube.searchPage( + body = SearchBody(query = query, params = Innertube.SearchFilter.Song.value), + fromMusicShelfRendererContent = Innertube.SongItem.Companion::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.SongItem.Companion::from + ) + } + }, + emptyItemsText = emptyItemsText, + headerContent = headerContent, + itemContent = { song -> + SongItem( + song = song, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem, + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(song.asMediaItem) + binder?.setupRadio(song.info?.endpoint) + } + ) + ) + }, + itemPlaceholderContent = { + SongItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + + 1 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = InnertubeAlbumsPageSaver, + itemsPageProvider = { continuation -> + if (continuation == null) { + Innertube.searchPage( + body = SearchBody(query = query, params = Innertube.SearchFilter.Album.value), + fromMusicShelfRendererContent = Innertube.AlbumItem::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.AlbumItem::from + ) + } + }, + emptyItemsText = emptyItemsText, + headerContent = headerContent, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable(onClick = { albumRoute(album.key) }) + ) + + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + + 2 -> { + val thumbnailSizeDp = 64.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = innertubeItemsPageSaver(InnertubeArtistItemListSaver), + itemsPageProvider = { continuation -> + if (continuation == null) { + Innertube.searchPage( + body = SearchBody(query = query, params = Innertube.SearchFilter.Artist.value), + fromMusicShelfRendererContent = Innertube.ArtistItem::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.ArtistItem::from + ) + } + }, + emptyItemsText = emptyItemsText, + headerContent = headerContent, + itemContent = { artist -> + ArtistItem( + artist = artist, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable(onClick = { artistRoute(artist.key) }) + ) + }, + itemPlaceholderContent = { + ArtistItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + + 3 -> { + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val thumbnailHeightDp = 72.dp + val thumbnailWidthDp = 128.dp + + ItemsPage( + stateSaver = innertubeItemsPageSaver(InnertubeVideoItemListSaver), + itemsPageProvider = { continuation -> + if (continuation == null) { + Innertube.searchPage( + body = SearchBody(query = query, params = Innertube.SearchFilter.Video.value), + fromMusicShelfRendererContent = Innertube.VideoItem::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.VideoItem::from + ) + } + }, + emptyItemsText = emptyItemsText, + headerContent = headerContent, + itemContent = { video -> + VideoItem( + video = video, + thumbnailWidthDp = thumbnailWidthDp, + thumbnailHeightDp = thumbnailHeightDp, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + mediaItem = video.asMediaItem, + onDismiss = menuState::hide + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(video.asMediaItem) + binder?.setupRadio(video.info?.endpoint) + } + ) + ) + }, + itemPlaceholderContent = { + VideoItemPlaceholder( + thumbnailHeightDp = thumbnailHeightDp, + thumbnailWidthDp = thumbnailWidthDp + ) + } + ) + } + + 4, 5 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = innertubeItemsPageSaver(InnertubePlaylistItemListSaver), + itemsPageProvider = { continuation -> + if (continuation == null) { + val filter = if (tabIndex == 4) { + Innertube.SearchFilter.CommunityPlaylist + } else { + Innertube.SearchFilter.FeaturedPlaylist + } + + Innertube.searchPage( + body = SearchBody(query = query, params = filter.value), + fromMusicShelfRendererContent = Innertube.PlaylistItem::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.PlaylistItem::from + ) + } + }, + emptyItemsText = emptyItemsText, + headerContent = headerContent, + itemContent = { playlist -> + PlaylistItem( + playlist = playlist, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable(onClick = { playlistRoute(playlist.key) }) + ) + }, + itemPlaceholderContent = { + PlaylistItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/About.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/About.kt new file mode 100644 index 0000000..8c52397 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/About.kt @@ -0,0 +1,77 @@ +package it.vfsfitvnm.vimusic.ui.screens.settings + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import it.vfsfitvnm.vimusic.BuildConfig +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.secondary + +@ExperimentalAnimationApi +@Composable +fun About() { + val (colorPalette, typography) = LocalAppearance.current + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) + ) { + Header(title = "About") { + BasicText( + text = "v${BuildConfig.VERSION_NAME} by vfsfitvnm", + style = typography.s.secondary + ) + } + + SettingsEntryGroupText(title = "SOCIAL") + + SettingsEntry( + title = "GitHub", + text = "View the source code", + onClick = { + uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic") + } + ) + + SettingsGroupSpacer() + + SettingsEntryGroupText(title = "TROUBLESHOOTING") + + SettingsEntry( + title = "Report an issue", + text = "You will be redirected to GitHub", + onClick = { + uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic/issues/new?assignees=&labels=bug&template=bug_report.yaml") + } + ) + + SettingsEntry( + title = "Request a feature or suggest an idea", + text = "You will be redirected to GitHub", + onClick = { + uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic/issues/new?assignees=&labels=enhancement&template=feature_request.yaml") + } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AboutScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AboutScreen.kt deleted file mode 100644 index b6a39e1..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AboutScreen.kt +++ /dev/null @@ -1,100 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.BuildConfig -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.screens.SettingsDescription -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsTitle -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance - -@ExperimentalAnimationApi -@Composable -fun AboutScreen() { - val scrollState = rememberScrollState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val (colorPalette) = LocalAppearance.current - val uriHandler = LocalUriHandler.current - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(scrollState) - .padding(LocalPlayerAwarePaddingValues.current) - ) { - 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) - ) - } - - SettingsTitle(text = "About") - - SettingsDescription(text = "v${BuildConfig.VERSION_NAME}\nby vfsfitvnm") - - SettingsEntryGroupText(title = "SOCIAL") - - SettingsEntry( - title = "GitHub", - text = "View the source code", - onClick = { - uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic") - } - ) - - SettingsEntryGroupText(title = "TROUBLESHOOTING") - - SettingsEntry( - title = "Report an issue", - text = "You will be redirected to GitHub", - onClick = { - uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic/issues/new?assignees=&labels=bug&template=bug_report.yaml") - } - ) - - SettingsEntry( - title = "Request a feature or suggest an idea", - text = "You will be redirected to GitHub", - onClick = { - uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic/issues/new?assignees=&labels=enhancement&template=feature_request.yaml") - } - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettings.kt new file mode 100644 index 0000000..f6223d8 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettings.kt @@ -0,0 +1,106 @@ +package it.vfsfitvnm.vimusic.ui.screens.settings + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.enums.ColorPaletteMode +import it.vfsfitvnm.vimusic.enums.ColorPaletteName +import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey +import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey +import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey +import it.vfsfitvnm.vimusic.utils.rememberPreference +import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey + +@ExperimentalAnimationApi +@Composable +fun AppearanceSettings() { + val (colorPalette) = LocalAppearance.current + + var colorPaletteName by rememberPreference(colorPaletteNameKey, ColorPaletteName.Dynamic) + var colorPaletteMode by rememberPreference(colorPaletteModeKey, ColorPaletteMode.System) + var thumbnailRoundness by rememberPreference( + thumbnailRoundnessKey, + ThumbnailRoundness.Light + ) + var isShowingThumbnailInLockscreen by rememberPreference( + isShowingThumbnailInLockscreenKey, + false + ) + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) + ) { + Header(title = "Appearance") + + SettingsEntryGroupText(title = "COLORS") + + EnumValueSelectorSettingsEntry( + title = "Theme", + selectedValue = colorPaletteName, + onValueSelected = { colorPaletteName = it } + ) + + EnumValueSelectorSettingsEntry( + title = "Theme mode", + selectedValue = colorPaletteMode, + isEnabled = colorPaletteName != ColorPaletteName.PureBlack, + onValueSelected = { colorPaletteMode = it } + ) + + SettingsGroupSpacer() + + SettingsEntryGroupText(title = "SHAPES") + + EnumValueSelectorSettingsEntry( + title = "Thumbnail roundness", + selectedValue = thumbnailRoundness, + onValueSelected = { thumbnailRoundness = it }, + trailingContent = { + Spacer( + modifier = Modifier + .border(width = 1.dp, color = colorPalette.accent, shape = thumbnailRoundness.shape()) + .background(color = colorPalette.background1, shape = thumbnailRoundness.shape()) + .size(36.dp) + ) + } + ) + + SettingsGroupSpacer() + + SettingsEntryGroupText(title = "LOCKSCREEN") + + SwitchSettingEntry( + title = "Show song cover", + text = "Use the playing song cover as the lockscreen wallpaper", + isChecked = isShowingThumbnailInLockscreen, + onCheckedChange = { isShowingThumbnailInLockscreen = it } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettingsScreen.kt deleted file mode 100644 index 6dc7abf..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettingsScreen.kt +++ /dev/null @@ -1,126 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -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.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.ColorPaletteMode -import it.vfsfitvnm.vimusic.enums.ColorPaletteName -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.screens.EnumValueSelectorSettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsTitle -import it.vfsfitvnm.vimusic.ui.screens.SwitchSettingEntry -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey -import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey -import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey - -@ExperimentalAnimationApi -@Composable -fun AppearanceSettingsScreen() { - val scrollState = rememberScrollState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val (colorPalette) = LocalAppearance.current - - var colorPaletteName by rememberPreference(colorPaletteNameKey, ColorPaletteName.Dynamic) - var colorPaletteMode by rememberPreference(colorPaletteModeKey, ColorPaletteMode.System) - var thumbnailRoundness by rememberPreference( - thumbnailRoundnessKey, - ThumbnailRoundness.Light - ) - var isShowingThumbnailInLockscreen by rememberPreference( - isShowingThumbnailInLockscreenKey, - false - ) - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(scrollState) - .padding(LocalPlayerAwarePaddingValues.current) - ) { - 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) - ) - } - - SettingsTitle(text = "Appearance") - - SettingsEntryGroupText(title = "COLORS") - - EnumValueSelectorSettingsEntry( - title = "Theme", - selectedValue = colorPaletteName, - onValueSelected = { - colorPaletteName = it - } - ) - - EnumValueSelectorSettingsEntry( - title = "Theme mode", - selectedValue = colorPaletteMode, - isEnabled = colorPaletteName != ColorPaletteName.PureBlack, - onValueSelected = { - colorPaletteMode = it - } - ) - - SettingsEntryGroupText(title = "SHAPES") - - EnumValueSelectorSettingsEntry( - title = "Thumbnail roundness", - selectedValue = thumbnailRoundness, - onValueSelected = { - thumbnailRoundness = it - } - ) - - SettingsEntryGroupText(title = "LOCKSCREEN") - - SwitchSettingEntry( - title = "Show song cover", - text = "Use the playing song cover as the lockscreen wallpaper", - isChecked = isShowingThumbnailInLockscreen, - onCheckedChange = { isShowingThumbnailInLockscreen = it } - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/BackupAndRestoreScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/BackupAndRestoreScreen.kt deleted file mode 100644 index ed9938d..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/BackupAndRestoreScreen.kt +++ /dev/null @@ -1,173 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import android.annotation.SuppressLint -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.checkpoint -import it.vfsfitvnm.vimusic.internal -import it.vfsfitvnm.vimusic.path -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.service.PlayerService -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsGroupDescription -import it.vfsfitvnm.vimusic.ui.screens.SettingsTitle -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.intent -import java.io.FileInputStream -import java.io.FileOutputStream -import java.text.SimpleDateFormat -import java.util.Date -import kotlin.system.exitProcess - -@ExperimentalAnimationApi -@Composable -fun BackupAndRestoreScreen() { - val scrollState = rememberScrollState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val (colorPalette) = LocalAppearance.current - val context = LocalContext.current - - val backupLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.sqlite3")) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - - query { - Database.internal.checkpoint() - context.applicationContext.contentResolver.openOutputStream(uri) - ?.use { outputStream -> - FileInputStream(Database.internal.path).use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - } - - val restoreLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - - query { - Database.internal.checkpoint() - Database.internal.close() - - FileOutputStream(Database.internal.path).use { outputStream -> - context.applicationContext.contentResolver.openInputStream(uri) - ?.use { inputStream -> - inputStream.copyTo(outputStream) - } - } - - context.stopService(context.intent()) - exitProcess(0) - } - } - - var isShowingRestoreDialog by rememberSaveable { - mutableStateOf(false) - } - - if (isShowingRestoreDialog) { - ConfirmationDialog( - text = "The application will automatically close itself to avoid problems after restoring the database.", - onDismiss = { - isShowingRestoreDialog = false - }, - onConfirm = { - restoreLauncher.launch( - arrayOf( - "application/x-sqlite3", - "application/vnd.sqlite3", - "application/octet-stream" - ) - ) - }, - confirmText = "Ok" - ) - } - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(scrollState) - .padding(LocalPlayerAwarePaddingValues.current) - ) { - 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) - ) - } - - SettingsTitle(text = "Backup & Restore") - - SettingsEntryGroupText(title = "BACKUP") - - SettingsGroupDescription(text = "Personal preferences (i.e. the theme mode) and the cache are excluded.") - - SettingsEntry( - title = "Backup", - text = "Export the database to the external storage", - onClick = { - @SuppressLint("SimpleDateFormat") - val dateFormat = SimpleDateFormat("yyyyMMddHHmmss") - backupLauncher.launch("vimusic_${dateFormat.format(Date())}.db") - } - ) - - SettingsEntryGroupText(title = "RESTORE") - - SettingsGroupDescription(text = "Existing data will be overwritten.") - - SettingsEntry( - title = "Restore", - text = "Import the database from the external storage", - onClick = { - isShowingRestoreDialog = true - } - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettings.kt new file mode 100644 index 0000000..8ab6714 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettings.kt @@ -0,0 +1,119 @@ +package it.vfsfitvnm.vimusic.ui.screens.settings + +import android.text.format.Formatter +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import coil.Coil +import coil.annotation.ExperimentalCoilApi +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.enums.CoilDiskCacheMaxSize +import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.coilDiskCacheMaxSizeKey +import it.vfsfitvnm.vimusic.utils.exoPlayerDiskCacheMaxSizeKey +import it.vfsfitvnm.vimusic.utils.rememberPreference + +@OptIn(ExperimentalCoilApi::class) +@ExperimentalAnimationApi +@Composable +fun CacheSettings() { + val context = LocalContext.current + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + + var coilDiskCacheMaxSize by rememberPreference( + coilDiskCacheMaxSizeKey, + CoilDiskCacheMaxSize.`128MB` + ) + var exoPlayerDiskCacheMaxSize by rememberPreference( + exoPlayerDiskCacheMaxSizeKey, + ExoPlayerDiskCacheMaxSize.`2GB` + ) + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) + ) { + Header(title = "Cache") + + SettingsDescription(text = "When the cache runs out of space, the resources that haven't been accessed for the longest time are cleared") + + Coil.imageLoader(context).diskCache?.let { diskCache -> + val diskCacheSize = remember(diskCache) { + diskCache.size + } + + SettingsGroupSpacer() + + SettingsEntryGroupText(title = "IMAGE CACHE") + + SettingsDescription( + text = "${ + Formatter.formatShortFileSize( + context, + diskCacheSize + ) + } used (${diskCacheSize * 100 / coilDiskCacheMaxSize.bytes.coerceAtLeast(1)}%)" + ) + + EnumValueSelectorSettingsEntry( + title = "Max size", + selectedValue = coilDiskCacheMaxSize, + onValueSelected = { coilDiskCacheMaxSize = it } + ) + } + + binder?.cache?.let { cache -> + val diskCacheSize by remember { + derivedStateOf { + cache.cacheSpace + } + } + + SettingsGroupSpacer() + + SettingsEntryGroupText(title = "SONG CACHE") + + SettingsDescription( + text = buildString { + append(Formatter.formatShortFileSize(context, diskCacheSize)) + append(" used") + when (val size = exoPlayerDiskCacheMaxSize) { + ExoPlayerDiskCacheMaxSize.Unlimited -> {} + else -> append(" (${diskCacheSize * 100 / size.bytes}%)") + } + } + ) + + EnumValueSelectorSettingsEntry( + title = "Max size", + selectedValue = exoPlayerDiskCacheMaxSize, + onValueSelected = { exoPlayerDiskCacheMaxSize = it } + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettingsScreen.kt deleted file mode 100644 index 0e511e4..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettingsScreen.kt +++ /dev/null @@ -1,144 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import android.text.format.Formatter -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import coil.Coil -import coil.annotation.ExperimentalCoilApi -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.CoilDiskCacheMaxSize -import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.screens.EnumValueSelectorSettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsDescription -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsGroupDescription -import it.vfsfitvnm.vimusic.ui.screens.SettingsTitle -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.coilDiskCacheMaxSizeKey -import it.vfsfitvnm.vimusic.utils.exoPlayerDiskCacheMaxSizeKey -import it.vfsfitvnm.vimusic.utils.rememberPreference - -@OptIn(ExperimentalCoilApi::class) -@ExperimentalAnimationApi -@Composable -fun CacheSettingsScreen() { - - val scrollState = rememberScrollState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val context = LocalContext.current - val (colorPalette, _) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - - var coilDiskCacheMaxSize by rememberPreference( - coilDiskCacheMaxSizeKey, - CoilDiskCacheMaxSize.`128MB` - ) - var exoPlayerDiskCacheMaxSize by rememberPreference( - exoPlayerDiskCacheMaxSizeKey, - ExoPlayerDiskCacheMaxSize.`2GB` - ) - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(scrollState) - .padding(LocalPlayerAwarePaddingValues.current) - ) { - 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) - ) - } - - SettingsTitle(text = "Cache") - - SettingsDescription(text = "When the cache runs out of space, the resources that haven't been accessed for the longest time are cleared.") - - Coil.imageLoader(context).diskCache?.let { diskCache -> - val diskCacheSize = remember(diskCache) { - diskCache.size - } - - SettingsEntryGroupText(title = "IMAGE CACHE") - - SettingsGroupDescription(text = "${Formatter.formatShortFileSize(context, diskCacheSize)} used (${diskCacheSize * 100 / coilDiskCacheMaxSize.bytes.coerceAtLeast(1)}%)") - - EnumValueSelectorSettingsEntry( - title = "Max size", - selectedValue = coilDiskCacheMaxSize, - onValueSelected = { - coilDiskCacheMaxSize = it - } - ) - } - - binder?.cache?.let { cache -> - val diskCacheSize by remember { - derivedStateOf { - cache.cacheSpace - } - } - - SettingsEntryGroupText(title = "SONG CACHE") - - SettingsGroupDescription( - text = buildString { - append(Formatter.formatShortFileSize(context, diskCacheSize)) - append(" used") - when (val size = exoPlayerDiskCacheMaxSize) { - ExoPlayerDiskCacheMaxSize.Unlimited -> {} - else -> append(" (${diskCacheSize * 100 / size.bytes}%)") - } - } - ) - - EnumValueSelectorSettingsEntry( - title = "Max size", - selectedValue = exoPlayerDiskCacheMaxSize, - onValueSelected = { - exoPlayerDiskCacheMaxSize = it - } - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/DatabaseSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/DatabaseSettings.kt new file mode 100644 index 0000000..37a4d3f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/DatabaseSettings.kt @@ -0,0 +1,157 @@ +package it.vfsfitvnm.vimusic.ui.screens.settings + +import android.annotation.SuppressLint +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.autoSaver +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.checkpoint +import it.vfsfitvnm.vimusic.internal +import it.vfsfitvnm.vimusic.path +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.service.PlayerService +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.intent +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import java.io.FileInputStream +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import kotlin.system.exitProcess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn + +@ExperimentalAnimationApi +@Composable +fun DatabaseSettings() { + val context = LocalContext.current + val (colorPalette) = LocalAppearance.current + + val queriesCount by produceSaveableState(initialValue = 0, stateSaver = autoSaver()) { + Database.queriesCount() + .flowOn(Dispatchers.IO) + .distinctUntilChanged() + .collect { value = it } + } + + val backupLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.sqlite3")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + + query { + Database.internal.checkpoint() + + context.applicationContext.contentResolver.openOutputStream(uri) + ?.use { outputStream -> + FileInputStream(Database.internal.path).use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } + } + + val restoreLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + + query { + Database.internal.checkpoint() + Database.internal.close() + + context.applicationContext.contentResolver.openInputStream(uri) + ?.use { inputStream -> + FileOutputStream(Database.internal.path).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + context.stopService(context.intent()) + exitProcess(0) + } + } + + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) + ) { + Header(title = "Database") + + SettingsEntryGroupText(title = "SEARCH HISTORY") + + SettingsEntry( + title = "Clear search history", + text = if (queriesCount > 0) { + "Delete $queriesCount search queries" + } else { + "History is empty" + }, + isEnabled = queriesCount > 0, + onClick = { + query { + Database.clearQueries() + } + } + ) + + SettingsGroupSpacer() + + SettingsEntryGroupText(title = "BACKUP") + + SettingsDescription(text = "Personal preferences (i.e. the theme mode) and the cache are excluded.") + + SettingsEntry( + title = "Backup", + text = "Export the database to the external storage", + onClick = { + @SuppressLint("SimpleDateFormat") + val dateFormat = SimpleDateFormat("yyyyMMddHHmmss") + backupLauncher.launch("vimusic_${dateFormat.format(Date())}.db") + } + ) + + SettingsGroupSpacer() + + SettingsEntryGroupText(title = "RESTORE") + + ImportantSettingsDescription(text = "Existing data will be overwritten.\n${context.applicationInfo.nonLocalizedLabel} will automatically close itself after restoring the database.") + + SettingsEntry( + title = "Restore", + text = "Import the database from the external storage", + onClick = { + restoreLauncher.launch( + arrayOf( + "application/x-sqlite3", + "application/vnd.sqlite3", + "application/octet-stream" + ) + ) + } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettings.kt new file mode 100644 index 0000000..664e8a9 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettings.kt @@ -0,0 +1,116 @@ +package it.vfsfitvnm.vimusic.ui.screens.settings + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.isIgnoringBatteryOptimizations +import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey +import it.vfsfitvnm.vimusic.utils.rememberPreference + +@ExperimentalAnimationApi +@Composable +fun OtherSettings() { + val context = LocalContext.current + val (colorPalette) = LocalAppearance.current + + var isInvincibilityEnabled by rememberPreference(isInvincibilityEnabledKey, false) + + var isIgnoringBatteryOptimizations by remember { + mutableStateOf(context.isIgnoringBatteryOptimizations) + } + + val activityResultLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations + } + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) + ) { + Header(title = "Other") + + SettingsEntryGroupText(title = "SERVICE LIFETIME") + + SettingsDescription(text = "If battery optimizations are applied, the playback notification can suddenly disappear when paused.") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SettingsDescription(text = "Since Android 12, disabling battery optimizations is required for the \"Invincible service\" option to take effect.") + } + + SettingsEntry( + title = "Ignore battery optimizations", + isEnabled = !isIgnoringBatteryOptimizations, + text = if (isIgnoringBatteryOptimizations) { + "Already unrestricted" + } else { + "Disable background restrictions" + }, + onClick = { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return@SettingsEntry + + @SuppressLint("BatteryLife") + val intent = + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + + if (intent.resolveActivity(context.packageManager) != null) { + activityResultLauncher.launch(intent) + } else { + val fallbackIntent = + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + + if (fallbackIntent.resolveActivity(context.packageManager) != null) { + activityResultLauncher.launch(fallbackIntent) + } else { + Toast.makeText( + context, + "Couldn't find battery optimization settings, please whitelist ViMusic manually", + Toast.LENGTH_SHORT + ).show() + } + } + } + ) + + SwitchSettingEntry( + title = "Invincible service", + text = "When turning off battery optimizations is not enough", + isChecked = isInvincibilityEnabled, + onCheckedChange = { isInvincibilityEnabled = it } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsScreen.kt deleted file mode 100644 index 0729292..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsScreen.kt +++ /dev/null @@ -1,183 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import android.annotation.SuppressLint -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.Settings -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.screens.SettingsDescription -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsGroupDescription -import it.vfsfitvnm.vimusic.ui.screens.SwitchSettingEntry -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.isIgnoringBatteryOptimizations -import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.semiBold -import kotlinx.coroutines.Dispatchers - -@ExperimentalAnimationApi -@Composable -fun OtherSettingsScreen() { - - val scrollState = rememberScrollState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val context = LocalContext.current - val (colorPalette, typography) = LocalAppearance.current - - val queriesCount by remember { - Database.queriesCount() - }.collectAsState(initial = 0, context = Dispatchers.IO) - - var isInvincibilityEnabled by rememberPreference(isInvincibilityEnabledKey, false) - - var isIgnoringBatteryOptimizations by remember { - mutableStateOf(context.isIgnoringBatteryOptimizations) - } - - val activityResultLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations - } - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(scrollState) - .padding(LocalPlayerAwarePaddingValues.current) - ) { - 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 = "Other", - style = typography.m.semiBold, - modifier = Modifier - .padding(start = 40.dp) - .padding(all = 16.dp) - ) - - SettingsEntryGroupText(title = "SEARCH HISTORY") - - SettingsEntry( - title = "Clear search history", - text = if (queriesCount > 0) { - "Delete $queriesCount search queries" - } else { - "History is empty" - }, - isEnabled = queriesCount > 0, - onClick = { - query { - Database.clearQueries() - } - } - ) - - SettingsEntryGroupText(title = "SERVICE LIFETIME") - - SettingsGroupDescription(text = "If battery optimizations are applied, the playback notification can suddenly disappear when paused.") - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - SettingsDescription(text = "Since Android 12, disabling battery optimizations is required for the \"Invincible service\" option to take effect.") - } - - SettingsEntry( - title = "Ignore battery optimizations", - isEnabled = !isIgnoringBatteryOptimizations, - text = if (isIgnoringBatteryOptimizations) { - "Already unrestricted" - } else { - "Disable background restrictions" - }, - onClick = { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return@SettingsEntry - - @SuppressLint("BatteryLife") - val intent = - Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { - data = Uri.parse("package:${context.packageName}") - } - - if (intent.resolveActivity(context.packageManager) != null) { - activityResultLauncher.launch(intent) - } else { - val fallbackIntent = - Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) - - if (fallbackIntent.resolveActivity(context.packageManager) != null) { - activityResultLauncher.launch(fallbackIntent) - } else { - Toast.makeText( - context, - "Couldn't find battery optimization settings, please whitelist ViMusic manually", - Toast.LENGTH_SHORT - ).show() - } - } - } - ) - - SwitchSettingEntry( - title = "Invincible service", - text = "When turning off battery optimizations is not enough", - isChecked = isInvincibilityEnabled, - onCheckedChange = { - isInvincibilityEnabled = it - } - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettings.kt new file mode 100644 index 0000000..5460c2a --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettings.kt @@ -0,0 +1,119 @@ +package it.vfsfitvnm.vimusic.ui.screens.settings + +import android.content.Intent +import android.media.audiofx.AudioEffect +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.persistentQueueKey +import it.vfsfitvnm.vimusic.utils.rememberPreference +import it.vfsfitvnm.vimusic.utils.skipSilenceKey +import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey + +@ExperimentalAnimationApi +@Composable +fun PlayerSettings() { + val context = LocalContext.current + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + + var persistentQueue by rememberPreference(persistentQueueKey, false) + var skipSilence by rememberPreference(skipSilenceKey, false) + var volumeNormalization by rememberPreference(volumeNormalizationKey, false) + + val activityResultLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + } + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) + ) { + Header(title = "Player & Audio") + + SettingsEntryGroupText(title = "PLAYER") + + SwitchSettingEntry( + title = "Persistent queue", + text = "Save and restore playing songs", + isChecked = persistentQueue, + onCheckedChange = { + persistentQueue = it + } + ) + + SettingsGroupSpacer() + + SettingsEntryGroupText(title = "AUDIO") + + SwitchSettingEntry( + title = "Skip silence", + text = "Skip silent parts during playback", + isChecked = skipSilence, + onCheckedChange = { + skipSilence = it + } + ) + + SwitchSettingEntry( + title = "Loudness normalization", + text = "Lower the volume to a standard level", + isChecked = volumeNormalization, + onCheckedChange = { + volumeNormalization = it + } + ) + + SettingsEntry( + title = "Equalizer", + text = "Interact with the system equalizer", + onClick = { + val intent = + Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra( + AudioEffect.EXTRA_AUDIO_SESSION, + binder?.player?.audioSessionId + ) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + putExtra( + AudioEffect.EXTRA_CONTENT_TYPE, + AudioEffect.CONTENT_TYPE_MUSIC + ) + } + + if (intent.resolveActivity(context.packageManager) != null) { + activityResultLauncher.launch(intent) + } else { + Toast.makeText(context, "No equalizer app found!", Toast.LENGTH_SHORT) + .show() + } + } + ) + } +} 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 deleted file mode 100644 index 17cd475..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt +++ /dev/null @@ -1,148 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import android.content.Intent -import android.media.audiofx.AudioEffect -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsTitle -import it.vfsfitvnm.vimusic.ui.screens.SwitchSettingEntry -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.persistentQueueKey -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.skipSilenceKey -import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey - -@ExperimentalAnimationApi -@Composable -fun PlayerSettingsScreen() { - - val scrollState = rememberScrollState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val context = LocalContext.current - val (colorPalette) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - - var persistentQueue by rememberPreference(persistentQueueKey, false) - var skipSilence by rememberPreference(skipSilenceKey, false) - var volumeNormalization by rememberPreference(volumeNormalizationKey, false) - - val activityResultLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - } - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(scrollState) - .padding(LocalPlayerAwarePaddingValues.current) - ) { - 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) - ) - } - - SettingsTitle(text = "Player & Audio") - - SettingsEntryGroupText(title = "PLAYER") - - SwitchSettingEntry( - title = "Persistent queue", - text = "Save and restore playing songs", - isChecked = persistentQueue, - onCheckedChange = { - persistentQueue = it - } - ) - - SettingsEntryGroupText(title = "AUDIO") - - SwitchSettingEntry( - title = "Skip silence", - text = "Skip silent parts during playback", - isChecked = skipSilence, - onCheckedChange = { - skipSilence = it - } - ) - - SwitchSettingEntry( - title = "Loudness normalization", - text = "Lower the volume to a standard level", - isChecked = volumeNormalization, - onCheckedChange = { - volumeNormalization = it - } - ) - - SettingsEntry( - title = "Equalizer", - text = "Interact with the system equalizer", - onClick = { - val intent = - Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { - putExtra( - AudioEffect.EXTRA_AUDIO_SESSION, - binder?.player?.audioSessionId - ) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - putExtra( - AudioEffect.EXTRA_CONTENT_TYPE, - AudioEffect.CONTENT_TYPE_MUSIC - ) - } - - if (intent.resolveActivity(context.packageManager) != null) { - activityResultLauncher.launch(intent) - } else { - Toast.makeText(context, "No equalizer app found!", Toast.LENGTH_SHORT) - .show() - } - } - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..fa15bbf --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,241 @@ +package it.vfsfitvnm.vimusic.ui.screens.settings + +import androidx.compose.animation.* +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.* +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.route.* +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.components.themed.Switch +import it.vfsfitvnm.vimusic.ui.components.themed.ValueSelectorDialog +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes +import it.vfsfitvnm.vimusic.ui.screens.settings.* +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.* + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun SettingsScreen() { + val saveableStateHolder = rememberSaveableStateHolder() + + val (tabIndex, onTabChanged) = rememberSaveable { + mutableStateOf(0) + } + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChanged = onTabChanged, + tabColumnContent = { Item -> + Item(0, "Appearance", R.drawable.color_palette) + Item(1, "Player", R.drawable.play) + Item(2, "Cache", R.drawable.server) + Item(3, "Database", R.drawable.server) + Item(4, "Other", R.drawable.shapes) + Item(5, "About", R.drawable.information) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(currentTabIndex) { + when (currentTabIndex) { + 0 -> AppearanceSettings() + 1 -> PlayerSettings() + 2 -> CacheSettings() + 3 -> DatabaseSettings() + 4 -> OtherSettings() + 5 -> About() + } + } + } + } + } +} + +@Composable +inline fun > EnumValueSelectorSettingsEntry( + title: String, + selectedValue: T, + crossinline onValueSelected: (T) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + crossinline valueText: (T) -> String = Enum::name, + noinline trailingContent: (@Composable () -> Unit)? = null +) { + ValueSelectorSettingsEntry( + title = title, + selectedValue = selectedValue, + values = enumValues().toList(), + onValueSelected = onValueSelected, + modifier = modifier, + isEnabled = isEnabled, + valueText = valueText, + trailingContent = trailingContent, + ) +} + +@Composable +inline fun ValueSelectorSettingsEntry( + title: String, + selectedValue: T, + values: List, + crossinline onValueSelected: (T) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + crossinline valueText: (T) -> String = { it.toString() }, + noinline trailingContent: (@Composable () -> Unit)? = null +) { + var isShowingDialog by remember { + mutableStateOf(false) + } + + if (isShowingDialog) { + ValueSelectorDialog( + onDismiss = { isShowingDialog = false }, + title = title, + selectedValue = selectedValue, + values = values, + onValueSelected = onValueSelected, + valueText = valueText + ) + } + + SettingsEntry( + title = title, + text = valueText(selectedValue), + modifier = modifier, + isEnabled = isEnabled, + onClick = { isShowingDialog = true }, + trailingContent = trailingContent + ) +} + +@Composable +fun SwitchSettingEntry( + title: String, + text: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true +) { + SettingsEntry( + title = title, + text = text, + isEnabled = isEnabled, + onClick = { onCheckedChange(!isChecked) }, + trailingContent = { Switch(isChecked = isChecked) }, + modifier = modifier + ) +} + +@Composable +fun SettingsEntry( + title: String, + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + isEnabled: Boolean = true, + trailingContent: (@Composable () -> Unit)? = null +) { + val (colorPalette, typography) = LocalAppearance.current + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable(enabled = isEnabled, onClick = onClick) + .alpha(if (isEnabled) 1f else 0.5f) + .padding(start = 16.dp) + .padding(all = 16.dp) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + BasicText( + text = title, + style = typography.xs.semiBold.copy(color = colorPalette.text), + ) + + BasicText( + text = text, + style = typography.xs.semiBold.copy(color = colorPalette.textSecondary), + ) + } + + trailingContent?.invoke() + } +} + +@Composable +fun SettingsDescription( + text: String, + modifier: Modifier = Modifier, +) { + val (_, typography) = LocalAppearance.current + + BasicText( + text = text, + style = typography.xxs.secondary, + modifier = modifier + .padding(start = 16.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + +@Composable +fun ImportantSettingsDescription( + text: String, + modifier: Modifier = Modifier, +) { + val (colorPalette, typography) = LocalAppearance.current + + BasicText( + text = text, + style = typography.xxs.semiBold.color(colorPalette.red), + modifier = modifier + .padding(start = 16.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + +@Composable +fun SettingsEntryGroupText( + title: String, + modifier: Modifier = Modifier, +) { + val (colorPalette, typography) = LocalAppearance.current + + BasicText( + text = title.uppercase(), + style = typography.xxs.semiBold.copy(colorPalette.accent), + modifier = modifier + .padding(start = 16.dp) + .padding(horizontal = 16.dp) + ) +} + +@Composable +fun SettingsGroupSpacer( + modifier: Modifier = Modifier, +) { + Spacer( + modifier = modifier + .height(24.dp) + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt index 051ae54..2e696ab 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt @@ -1,12 +1,57 @@ package it.vfsfitvnm.vimusic.ui.styling +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp data class Appearance( val colorPalette: ColorPalette, - val typography: Typography, + val typography: Typography = typographyOf(colorPalette.text), val thumbnailShape: Shape -) +) { + companion object : Saver> { + override fun restore(value: List) = Appearance( + colorPalette = ColorPalette( + background0 = Color((value[0] as Long).toULong()), + background1 = Color((value[1] as Long).toULong()), + background2 = Color((value[2] as Long).toULong()), + accent = Color((value[3] as Long).toULong()), + onAccent = Color((value[4] as Long).toULong()), + red = Color((value[5] as Long).toULong()), + blue = Color((value[6] as Long).toULong()), + text = Color((value[7] as Long).toULong()), + textSecondary = Color((value[8] as Long).toULong()), + textDisabled = Color((value[9] as Long).toULong()), + isDark = value[10] as Boolean + ), + thumbnailShape = RoundedCornerShape((value[11] as Int).dp) + ) + + override fun SaverScope.save(value: Appearance) = + listOf( + value.colorPalette.background0.value.toLong(), + value.colorPalette.background1.value.toLong(), + value.colorPalette.background2.value.toLong(), + value.colorPalette.accent.value.toLong(), + value.colorPalette.onAccent.value.toLong(), + value.colorPalette.red.value.toLong(), + value.colorPalette.blue.value.toLong(), + value.colorPalette.text.value.toLong(), + value.colorPalette.textSecondary.value.toLong(), + value.colorPalette.textDisabled.value.toLong(), + value.colorPalette.isDark, + when (value.thumbnailShape) { + RoundedCornerShape(2.dp) -> 2 + RoundedCornerShape(4.dp) -> 4 + RoundedCornerShape(8.dp) -> 8 + else -> 0 + } + ) + } +} val LocalAppearance = staticCompositionLocalOf { TODO() } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt index 37eae8b..225ff5d 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt @@ -10,6 +10,11 @@ import androidx.compose.ui.unit.dp object Dimensions { val itemsVerticalPadding = 8.dp + val navigationRailWidth = 64.dp + val navigationRailWidthLandscape = 128.dp + val navigationRailIconOffset = 6.dp + val headerHeight = 128.dp + object thumbnails { val album = 128.dp val artist = 192.dp @@ -17,7 +22,6 @@ object Dimensions { val playlist = album object player { - val songPreview = collapsedPlayer val song: Dp @Composable get() = with(LocalConfiguration.current) { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt index 6b57ecb..d17b065 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt @@ -17,6 +17,7 @@ data class Typography( val s: TextStyle, val m: TextStyle, val l: TextStyle, + val xxl: TextStyle, ) fun typographyOf(color: Color): Typography { @@ -54,5 +55,6 @@ fun typographyOf(color: Color): Typography { s = textStyle.copy(fontSize = 16.sp), m = textStyle.copy(fontSize = 18.sp), l = textStyle.copy(fontSize = 20.sp), + xxl = textStyle.copy(fontSize = 32.sp), ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt deleted file mode 100644 index 9cd5103..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt +++ /dev/null @@ -1,288 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.views - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.reordering.ReorderingLazyColumn -import it.vfsfitvnm.reordering.animateItemPlacement -import it.vfsfitvnm.reordering.draggedItem -import it.vfsfitvnm.reordering.rememberReorderingState -import it.vfsfitvnm.reordering.reorder -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.ui.components.BottomSheet -import it.vfsfitvnm.vimusic.ui.components.BottomSheetState -import it.vfsfitvnm.vimusic.ui.components.MusicBars -import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.onOverlay -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex -import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying -import it.vfsfitvnm.vimusic.utils.rememberWindows -import it.vfsfitvnm.vimusic.utils.shuffleQueue -import kotlinx.coroutines.launch - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun PlayerBottomSheet( - backgroundColorProvider: () -> Color, - layoutState: BottomSheetState, - onGlobalRouteEmitted: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable BoxScope.() -> Unit, -) { - val (colorPalette, typography) = LocalAppearance.current - - BottomSheet( - state = layoutState, - modifier = modifier, - collapsedContent = { - Box( - modifier = Modifier - .drawBehind { drawRect(backgroundColorProvider()) } - .fillMaxSize() - .navigationBarsPadding() - ) { - Image( - painter = painterResource(R.drawable.playlist), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(18.dp) - ) - - content() - } - } - ) { - val binder = LocalPlayerServiceBinder.current - val layoutDirection = LocalLayoutDirection.current - - binder?.player ?: return@BottomSheet - - val thumbnailSize = Dimensions.thumbnails.song.px - - val mediaItemIndex by rememberMediaItemIndex(binder.player) - val windows by rememberWindows(binder.player) - val shouldBePlaying by rememberShouldBePlaying(binder.player) - - val reorderingState = rememberReorderingState( - lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex), - key = windows, - onDragEnd = { fromIndex, toIndex -> - binder.player.moveMediaItem(fromIndex, toIndex) - }, - extraItemCount = 0 - ) - - val paddingValues = WindowInsets.systemBars.asPaddingValues() - val bottomPadding = paddingValues.calculateBottomPadding() - - Column { - ReorderingLazyColumn( - reorderingState = reorderingState, - contentPadding = PaddingValues( - top = paddingValues.calculateTopPadding(), - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - ), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .nestedScroll(remember { - layoutState.nestedScrollConnection(reorderingState.lazyListState.firstVisibleItemIndex == 0 && reorderingState.lazyListState.firstVisibleItemScrollOffset == 0) - }) - .background(colorPalette.background1) - .weight(1f) - ) { - items( - items = windows, - key = { it.uid.hashCode() } - ) { window -> - val isPlayingThisMediaItem = mediaItemIndex == window.firstPeriodIndex - - SongItem( - mediaItem = window.mediaItem, - thumbnailSize = thumbnailSize, - onClick = { - if (isPlayingThisMediaItem) { - if (shouldBePlaying) { - binder.player.pause() - } else { - binder.player.play() - } - } else { - binder.player.playWhenReady = true - binder.player.seekToDefaultPosition(window.firstPeriodIndex) - } - }, - menuContent = { - QueuedMediaItemMenu( - mediaItem = window.mediaItem, - indexInQueue = if (isPlayingThisMediaItem) null else window.firstPeriodIndex, - onGlobalRouteEmitted = onGlobalRouteEmitted - ) - }, - onThumbnailContent = { - androidx.compose.animation.AnimatedVisibility( - visible = isPlayingThisMediaItem, - enter = fadeIn(), - exit = fadeOut(), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .background( - color = Color.Black.copy(alpha = 0.25f), - shape = ThumbnailRoundness.shape - ) - .size(Dimensions.thumbnails.song) - ) { - if (shouldBePlaying) { - MusicBars( - color = colorPalette.onOverlay, - modifier = Modifier - .height(24.dp) - ) - } else { - Image( - painter = painterResource(R.drawable.play), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.onOverlay), - modifier = Modifier - .size(24.dp) - ) - } - } - } - }, - trailingContent = { - Image( - painter = painterResource(R.drawable.reorder), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textSecondary), - modifier = Modifier - .clickable { } - .reorder( - reorderingState = reorderingState, - index = window.firstPeriodIndex - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - .size(20.dp) - ) - }, - modifier = Modifier - .animateItemPlacement(reorderingState) - .draggedItem( - reorderingState = reorderingState, - index = window.firstPeriodIndex - ) - ) - } - - item { - if (binder.isLoadingRadio) { - Column( - modifier = Modifier - .shimmer() - ) { - repeat(3) { index -> - SmallSongItemShimmer( - thumbnailSizeDp = Dimensions.thumbnails.song, - modifier = Modifier - .alpha(1f - index * 0.125f) - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 16.dp) - ) - } - } - } - } - } - - Box( - modifier = Modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = layoutState::collapseSoft - ) - .height(64.dp + bottomPadding) - .background(colorPalette.background2) - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(bottom = bottomPadding) - ) { - BasicText( - text = "${windows.size} songs", - style = typography.xxs.medium, - modifier = Modifier - .padding(start = 4.dp) - .background(color = colorPalette.background1, shape = RoundedCornerShape(16.dp)) - .align(Alignment.CenterStart) - .padding(all = 8.dp) - ) - - Image( - painter = painterResource(R.drawable.chevron_down), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(18.dp) - ) - - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .padding(end = 2.dp) - .clickable { - reorderingState.coroutineScope.launch { - reorderingState.lazyListState.animateScrollToItem(0) - }.invokeOnCompletion { - binder.player.shuffleQueue() - } - } - .align(Alignment.CenterEnd) - .padding(all = 8.dp) - .size(20.dp) - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt deleted file mode 100644 index 6461bec..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt +++ /dev/null @@ -1,410 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.views - -import android.content.Intent -import android.content.res.Configuration -import android.media.audiofx.AudioEffect -import android.widget.Toast -import androidx.activity.compose.LocalActivityResultRegistryOwner -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.media3.common.Player -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.BottomSheet -import it.vfsfitvnm.vimusic.ui.components.BottomSheetState -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState -import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.collapsedPlayerProgressBar -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.views.player.Controls -import it.vfsfitvnm.vimusic.ui.views.player.Thumbnail -import it.vfsfitvnm.vimusic.utils.rememberMediaItem -import it.vfsfitvnm.vimusic.utils.rememberPositionAndDuration -import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying -import it.vfsfitvnm.vimusic.utils.seamlessPlay -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint -import kotlin.math.absoluteValue - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun PlayerView( - layoutState: BottomSheetState, - modifier: Modifier = Modifier, -) { - val menuState = LocalMenuState.current - - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - val context = LocalContext.current - val configuration = LocalConfiguration.current - val layoutDirection = LocalLayoutDirection.current - - binder?.player ?: return - - val nullableMediaItem by rememberMediaItem(binder.player) - - val mediaItem = nullableMediaItem ?: return - - val shouldBePlaying by rememberShouldBePlaying(binder.player) - val positionAndDuration by rememberPositionAndDuration(binder.player) - - BottomSheet( - state = layoutState, - modifier = modifier, - onDismiss = { - binder.stopRadio() - binder.player.clearMediaItems() - }, - collapsedContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - modifier = Modifier - .background(colorPalette.background1) - .fillMaxSize() - .navigationBarsPadding() - .drawBehind { - val progress = - positionAndDuration.first.toFloat() / positionAndDuration.second.absoluteValue - - drawLine( - color = colorPalette.collapsedPlayerProgressBar, - start = Offset(x = 0f, y = 1.dp.toPx()), - end = Offset(x = size.width * progress, y = 1.dp.toPx()), - strokeWidth = 2.dp.toPx() - ) - } - ) { - Spacer( - modifier = Modifier - .width(2.dp) - ) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .height(Dimensions.collapsedPlayer) - ) { - AsyncImage( - model = mediaItem.mediaMetadata.artworkUri.thumbnail(Dimensions.thumbnails.player.songPreview.px), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(thumbnailShape) - .size(48.dp) - ) - } - - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier - .height(Dimensions.collapsedPlayer) - .weight(1f) - ) { - BasicText( - text = mediaItem.mediaMetadata.title?.toString() ?: "", - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = mediaItem.mediaMetadata.artist?.toString() ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - Spacer( - modifier = Modifier - .width(2.dp) - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .height(Dimensions.collapsedPlayer) - ) { - Box( - modifier = Modifier - .clickable { - if (shouldBePlaying) { - binder.player.pause() - } else { - if (binder.player.playbackState == Player.STATE_IDLE) { - binder.player.prepare() - } - binder.player.play() - } - } - .padding(horizontal = 4.dp, vertical = 8.dp) - ) { - Image( - painter = painterResource(if (shouldBePlaying) R.drawable.pause else R.drawable.play), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(20.dp) - ) - } - - Box( - modifier = Modifier - .clickable(onClick = binder.player::seekToNext) - .padding(horizontal = 4.dp, vertical = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.play_skip_forward), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(20.dp) - ) - } - } - - Spacer( - modifier = Modifier - .width(2.dp) - ) - } - } - ) { - var isShowingLyrics by rememberSaveable { - mutableStateOf(false) - } - - var isShowingStatsForNerds by rememberSaveable { - mutableStateOf(false) - } - - val paddingValues = WindowInsets.navigationBars.asPaddingValues() - val playerBottomSheetState = rememberBottomSheetState(64.dp + paddingValues.calculateBottomPadding(), layoutState.expandedBound) - - when (configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .background(colorPalette.background1) - .padding( - top = 32.dp + paddingValues.calculateTopPadding(), - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - bottom = playerBottomSheetState.collapsedBound - ) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(0.66f) - .padding(bottom = 16.dp) - ) { - Thumbnail( - isShowingLyrics = isShowingLyrics, - onShowLyrics = { isShowingLyrics = it }, - isShowingStatsForNerds = isShowingStatsForNerds, - onShowStatsForNerds = { isShowingStatsForNerds = it }, - nestedScrollConnectionProvider = layoutState::nestedScrollConnection, - modifier = Modifier - .padding(horizontal = 16.dp) - ) - } - - Controls( - mediaId = mediaItem.mediaId, - title = mediaItem.mediaMetadata.title?.toString(), - artist = mediaItem.mediaMetadata.artist?.toString(), - shouldBePlaying = shouldBePlaying, - position = positionAndDuration.first, - duration = positionAndDuration.second, - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxHeight() - .weight(1f) - ) - } - } - else -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .background(colorPalette.background1) - .padding( - top = 54.dp + paddingValues.calculateTopPadding(), - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - bottom = playerBottomSheetState.collapsedBound - ) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(1.25f) - ) { - Thumbnail( - isShowingLyrics = isShowingLyrics, - onShowLyrics = { isShowingLyrics = it }, - isShowingStatsForNerds = isShowingStatsForNerds, - onShowStatsForNerds = { isShowingStatsForNerds = it }, - nestedScrollConnectionProvider = layoutState::nestedScrollConnection, - modifier = Modifier - .padding(horizontal = 32.dp, vertical = 8.dp) - ) - } - - Controls( - mediaId = mediaItem.mediaId, - title = mediaItem.mediaMetadata.title?.toString(), - artist = mediaItem.mediaMetadata.artist?.toString(), - shouldBePlaying = shouldBePlaying, - position = positionAndDuration.first, - duration = positionAndDuration.second, - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxWidth() - .weight(1f) - ) - } - } - } - - PlayerBottomSheet( - layoutState = playerBottomSheetState, - onGlobalRouteEmitted = layoutState::collapseSoft, - content = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(horizontal = 8.dp) - .fillMaxHeight() - ) { - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - menuState.display { - val resultRegistryOwner = - LocalActivityResultRegistryOwner.current - - BaseMediaItemMenu( - mediaItem = mediaItem, - onStartRadio = { - binder.stopRadio() - binder.player.seamlessPlay(mediaItem) - binder.setupRadio( - NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) - ) - }, - onGoToEqualizer = { - val intent = - Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { - putExtra( - AudioEffect.EXTRA_AUDIO_SESSION, - binder.player.audioSessionId - ) - putExtra( - AudioEffect.EXTRA_PACKAGE_NAME, - context.packageName - ) - putExtra( - AudioEffect.EXTRA_CONTENT_TYPE, - AudioEffect.CONTENT_TYPE_MUSIC - ) - } - - if (intent.resolveActivity(context.packageManager) != null) { - val contract = - ActivityResultContracts.StartActivityForResult() - - resultRegistryOwner?.activityResultRegistry - ?.register("", contract) {} - ?.launch(intent) - } else { - Toast - .makeText( - context, - "No equalizer app found!", - Toast.LENGTH_SHORT - ) - .show() - } - }, - onSetSleepTimer = {}, - onDismiss = menuState::hide, - onGlobalRouteEmitted = layoutState::collapseSoft, - ) - } - } - .padding(all = 8.dp) - .size(20.dp) - ) - - Spacer( - modifier = Modifier - .width(4.dp) - ) - } - }, - backgroundColorProvider = { colorPalette.background2 }, - modifier = Modifier - .align(Alignment.BottomCenter) - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt deleted file mode 100644 index 13785d5..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt +++ /dev/null @@ -1,178 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.views - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.models.PlaylistPreview -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map - -@Composable -fun PlaylistPreviewItem( - playlistPreview: PlaylistPreview, - modifier: Modifier = Modifier, - thumbnailSize: Dp = Dimensions.thumbnails.song -) { - val density = LocalDensity.current - val (_, _, thumbnailShape) = LocalAppearance.current - - val thumbnailSizePx = with(density) { - thumbnailSize.roundToPx() - } - - val thumbnails by remember(playlistPreview.playlist.id) { - Database.playlistThumbnailUrls(playlistPreview.playlist.id).distinctUntilChanged().map { - it.map { url -> - url.thumbnail(thumbnailSizePx) - } - } - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - PlaylistItem( - name = playlistPreview.playlist.name, - textColor = Color.White, - thumbnailSize = thumbnailSize, - imageContent = { - if (thumbnails.toSet().size == 1) { - AsyncImage( - model = thumbnails.first().thumbnail(thumbnailSizePx * 2), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(thumbnailShape) - .fillMaxSize() - ) - } else { - Box( - modifier = Modifier - .fillMaxSize() - ) { - listOf( - Alignment.TopStart, - Alignment.TopEnd, - Alignment.BottomStart, - Alignment.BottomEnd - ).forEachIndexed { index, alignment -> - AsyncImage( - model = thumbnails.getOrNull(index), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(thumbnailShape) - .align(alignment) - .size(thumbnailSize) - ) - } - } - } - }, - modifier = modifier - ) -} - -@Composable -fun BuiltInPlaylistItem( - @DrawableRes icon: Int, - colorTint: Color, - name: String, - modifier: Modifier = Modifier, - thumbnailSize: Dp = Dimensions.thumbnails.song -) { - PlaylistItem( - name = name, - thumbnailSize = thumbnailSize, - withGradient = false, - imageContent = { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = ColorFilter.tint(colorTint), - modifier = Modifier - .align(Alignment.Center) - .size(24.dp) - ) - }, - modifier = modifier, - ) -} - -@Composable -fun PlaylistItem( - name: String, - modifier: Modifier = Modifier, - textColor: Color? = null, - thumbnailSize: Dp = Dimensions.thumbnails.song, - withGradient: Boolean = true, - imageContent: @Composable BoxScope.() -> Unit -) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - - Box( - modifier = modifier - .clip(thumbnailShape) - .background(colorPalette.background1) - .size(thumbnailSize * 2) - ) { - Box( - modifier = Modifier - .size(thumbnailSize * 2), - content = imageContent - ) - - BasicText( - text = name, - style = typography.xxs.semiBold.color(textColor ?: colorPalette.text), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomStart) - .run { - if (withGradient) { - background( - Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.75f) - ) - ) - ) - } else { - this - } - } - .padding(horizontal = 8.dp, vertical = 4.dp) - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt deleted file mode 100644 index 835d2e4..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt +++ /dev/null @@ -1,193 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.views - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaItem -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail - -@ExperimentalAnimationApi -@Composable -@NonRestartableComposable -fun SongItem( - mediaItem: MediaItem, - thumbnailSize: Int, - onClick: () -> Unit, - menuContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - SongItem( - thumbnailModel = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSize), - title = mediaItem.mediaMetadata.title!!.toString(), - authors = mediaItem.mediaMetadata.artist.toString(), - durationText = mediaItem.mediaMetadata.extras?.getString("durationText") ?: "?", - menuContent = menuContent, - onClick = onClick, - onThumbnailContent = onThumbnailContent, - trailingContent = trailingContent, - modifier = modifier, - ) -} - -@ExperimentalAnimationApi -@Composable -@NonRestartableComposable -fun SongItem( - song: DetailedSong, - thumbnailSize: Int, - onClick: () -> Unit, - menuContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - SongItem( - thumbnailModel = song.thumbnailUrl?.thumbnail(thumbnailSize), - title = song.title, - authors = song.artistsText ?: "", - durationText = song.durationText, - menuContent = menuContent, - onClick = onClick, - onThumbnailContent = onThumbnailContent, - trailingContent = trailingContent, - modifier = modifier, - ) -} - -@ExperimentalAnimationApi -@Composable -@NonRestartableComposable -fun SongItem( - thumbnailModel: Any?, - title: String, - authors: String, - durationText: String?, - onClick: () -> Unit, - menuContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - SongItem( - title = title, - authors = authors, - durationText = durationText, - onClick = onClick, - startContent = { - Box( - modifier = Modifier - .size(Dimensions.thumbnails.song) - ) { - AsyncImage( - model = thumbnailModel, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .fillMaxSize() - ) - - onThumbnailContent?.invoke(this) - } - }, - menuContent = menuContent, - trailingContent = trailingContent, - modifier = modifier, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@ExperimentalAnimationApi -@Composable -fun SongItem( - title: String, - authors: String?, - durationText: String?, - onClick: () -> Unit, - startContent: @Composable () -> Unit, - menuContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - trailingContent: (@Composable () -> Unit)? = null -) { - val menuState = LocalMenuState.current - val (_, typography) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - .combinedClickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onLongClick = { - menuState.display(menuContent) - }, - onClick = onClick - ) - .fillMaxWidth() - .padding(vertical = Dimensions.itemsVerticalPadding) - .padding(start = 16.dp, end = if (trailingContent == null) 16.dp else 8.dp) - ) { - startContent() - - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = title, - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = authors ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - durationText?.let { - BasicText( - text = durationText, - style = typography.xxs.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - trailingContent?.invoke() - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Configuration.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Configuration.kt new file mode 100644 index 0000000..6011a8f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Configuration.kt @@ -0,0 +1,11 @@ +package it.vfsfitvnm.vimusic.utils + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration + +val isLandscape + @Composable + @ReadOnlyComposable + get() = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyGridSnapLayoutInfoProvider.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyGridSnapLayoutInfoProvider.kt new file mode 100644 index 0000000..2fc20ba --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyGridSnapLayoutInfoProvider.kt @@ -0,0 +1,72 @@ +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.ui.unit.Density +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastSumBy + +fun Density.calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyGridLayoutInfo, + item: LazyGridItemInfo, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float +): Float { + val containerSize = + with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } + + val desiredDistance = positionInLayout(containerSize.toFloat(), item.size.width.toFloat()) + val itemCurrentPosition = item.offset.x.toFloat() + + return itemCurrentPosition - desiredDistance +} + +private val LazyGridLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width + +@ExperimentalFoundationApi +fun SnapLayoutInfoProvider( + lazyGridState: LazyGridState, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = + { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) } +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + + private val layoutInfo: LazyGridLayoutInfo + get() = lazyGridState.layoutInfo + + // Single page snapping is the default + override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f + + override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = + calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) + + // Find item that is closest to the center + if (offset <= 0 && offset > lowerBoundOffset) { + lowerBoundOffset = offset + } + + // Find item that is closest to center, but after it + if (offset >= 0 && offset < upperBoundOffset) { + upperBoundOffset = offset + } + } + + return lowerBoundOffset.rangeTo(upperBoundOffset) + } + + override fun Density.snapStepSize(): Float = with(layoutInfo) { + if (visibleItemsInfo.isNotEmpty()) { + visibleItemsInfo.fastSumBy { it.size.width } / visibleItemsInfo.size.toFloat() + } else { + 0f + } + } +} 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 de2c103..2ad759f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt @@ -21,14 +21,19 @@ const val songSortOrderKey = "songSortOrder" const val songSortByKey = "songSortBy" const val playlistSortOrderKey = "playlistSortOrder" const val playlistSortByKey = "playlistSortBy" -const val playlistGridExpandedKey = "playlistGridExpanded" -const val searchFilterKey = "searchFilter" +const val albumSortOrderKey = "albumSortOrder" +const val albumSortByKey = "albumSortBy" +const val artistSortOrderKey = "artistSortOrder" +const val artistSortByKey = "artistSortBy" const val repeatModeKey = "repeatMode" const val skipSilenceKey = "skipSilence" const val volumeNormalizationKey = "volumeNormalization" const val persistentQueueKey = "persistentQueue" const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics" const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen" +const val homeScreenTabIndexKey = "homeScreenTabIndex" +const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex" +const val artistScreenTabIndexKey = "artistScreenTabIndex" inline fun > SharedPreferences.getEnum( key: String, @@ -61,6 +66,16 @@ fun rememberPreference(key: String, defaultValue: Boolean): MutableState { + val context = LocalContext.current + return remember { + mutableStatePreferenceOf(context.preferences.getInt(key, defaultValue)) { + context.preferences.edit { putInt(key, it) } + } + } +} + @Composable fun rememberPreference(key: String, defaultValue: String): MutableState { val context = LocalContext.current diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt new file mode 100644 index 0000000..cf5ac25 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt @@ -0,0 +1,109 @@ +@file:OptIn(ExperimentalTypeInference::class) + +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProduceStateScope +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlin.coroutines.CoroutineContext +import kotlin.experimental.ExperimentalTypeInference +import kotlinx.coroutines.suspendCancellableCoroutine + +@Composable +fun produceSaveableState( + initialValue: T, + stateSaver: Saver, + @BuilderInference producer: suspend ProduceStateScope.() -> Unit +): State { + val result = rememberSaveable(stateSaver = stateSaver) { + mutableStateOf(initialValue) + } + + LaunchedEffect(Unit) { + ProduceSaveableStateScope(result, coroutineContext).producer() + } + + return result +} + +@Composable +fun produceSaveableState( + initialValue: T, + stateSaver: Saver, + key1: Any?, + @BuilderInference producer: suspend ProduceStateScope.() -> Unit +): State { + val state = rememberSaveable(stateSaver = stateSaver) { + mutableStateOf(initialValue) + } + + LaunchedEffect(key1) { + ProduceSaveableStateScope(state, coroutineContext).producer() + } + + return state +} + +@Composable +fun produceSaveableOneShotState( + initialValue: T, + stateSaver: Saver, + key1: Any?, + @BuilderInference producer: suspend ProduceStateScope.() -> Unit +): State { + val state = rememberSaveable(stateSaver = stateSaver) { + mutableStateOf(initialValue) + } + + var produced by rememberSaveable(key1) { + mutableStateOf(false) + } + + LaunchedEffect(key1) { + if (!produced) { + ProduceSaveableStateScope(state, coroutineContext).producer() + produced = true + } + } + + return state +} + +@Composable +fun produceSaveableState( + initialValue: T, + stateSaver: Saver, + key1: Any?, + key2: Any?, + @BuilderInference producer: suspend ProduceStateScope.() -> Unit +): State { + val result = rememberSaveable(stateSaver = stateSaver) { + mutableStateOf(initialValue) + } + + LaunchedEffect(key1, key2) { + ProduceSaveableStateScope(result, coroutineContext).producer() + } + + return result +} + +private class ProduceSaveableStateScope( + state: MutableState, + override val coroutineContext: CoroutineContext +) : ProduceStateScope, MutableState by state { + override suspend fun awaitDispose(onDispose: () -> Unit): Nothing { + try { + suspendCancellableCoroutine { } + } finally { + onDispose() + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt deleted file mode 100644 index f015d98..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt +++ /dev/null @@ -1,36 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") -@file:OptIn(InternalComposeApi::class) - -package it.vfsfitvnm.vimusic.utils - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.InternalComposeApi -import androidx.compose.runtime.LaunchedEffectImpl -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.currentComposer -import androidx.compose.runtime.remember -import kotlinx.coroutines.CoroutineScope - -@Composable -@NonRestartableComposable -fun relaunchableEffect( - key1: Any?, - block: suspend CoroutineScope.() -> Unit -): () -> Unit { - val applyContext = currentComposer.applyCoroutineContext - val launchedEffect = remember(key1) { LaunchedEffectImpl(applyContext, block) } - return launchedEffect::onRemembered -} - -@Composable -@NonRestartableComposable -fun relaunchableEffect( - key1: Any?, - key2: Any?, - block: suspend CoroutineScope.() -> Unit -): () -> Unit { - val applyContext = currentComposer.applyCoroutineContext - val launchedEffect = remember(key1, key2) { LaunchedEffectImpl(applyContext, block) } - return launchedEffect::onRemembered -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt index 64be0c3..6d403fc 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt @@ -1,7 +1,7 @@ package it.vfsfitvnm.vimusic.utils class RingBuffer(val size: Int, init: (index: Int) -> T) { - private val list = MutableList(2, init) + private val list = MutableList(size, init) private var index = 0 diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ScrollingInfo.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ScrollingInfo.kt new file mode 100644 index 0000000..6d0f521 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ScrollingInfo.kt @@ -0,0 +1,93 @@ +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +data class ScrollingInfo( + val isScrollingDown: Boolean = false, + val isFar: Boolean = false +) { + fun and(condition: Boolean) = +// copy(isScrollingDown = isScrollingDown && condition, isFar = isFar && condition) + if (condition) this else copy(isScrollingDown = !isScrollingDown, isFar = !isFar) +} + +@Composable +fun LazyListState.scrollingInfo(): ScrollingInfo { + var previousIndex by remember(this) { + mutableStateOf(firstVisibleItemIndex) + } + + var previousScrollOffset by remember(this) { + mutableStateOf(firstVisibleItemScrollOffset) + } + + return remember(this) { + derivedStateOf { + val isScrollingDown = if (previousIndex == firstVisibleItemIndex) { + firstVisibleItemScrollOffset > previousScrollOffset + } else { + firstVisibleItemIndex > previousIndex + } + + val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size + + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + + ScrollingInfo(isScrollingDown, isFar) + } + }.value +} + +@Composable +fun LazyGridState.scrollingInfo(): ScrollingInfo { + var previousIndex by remember(this) { + mutableStateOf(firstVisibleItemIndex) + } + + var previousScrollOffset by remember(this) { + mutableStateOf(firstVisibleItemScrollOffset) + } + + return remember(this) { + derivedStateOf { + val isScrollingDown = if (previousIndex == firstVisibleItemIndex) { + firstVisibleItemScrollOffset > previousScrollOffset + } else { + firstVisibleItemIndex > previousIndex + } + + val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size + + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + + ScrollingInfo(isScrollingDown, isFar) + } + }.value +} + +@Composable +fun ScrollState.scrollingInfo(): ScrollingInfo { + var previousValue by remember(this) { + mutableStateOf(value) + } + + return remember(this) { + derivedStateOf { + val isScrollingDown = value > previousValue + + previousValue = value + + ScrollingInfo(isScrollingDown, false) + } + }.value +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SmoothScrollToTop.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SmoothScrollToTop.kt new file mode 100644 index 0000000..02a75ff --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SmoothScrollToTop.kt @@ -0,0 +1,24 @@ +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +suspend fun LazyGridState.smoothScrollToTop() { + if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) { + scrollToItem(layoutInfo.visibleItemsInfo.size) + } + animateScrollToItem(0) +} + +suspend fun LazyListState.smoothScrollToTop() { + if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) { + scrollToItem(layoutInfo.visibleItemsInfo.size) + } + animateScrollToItem(0) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt index 269668c..a6c1e64 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt @@ -1,65 +1,56 @@ package it.vfsfitvnm.vimusic.utils -import android.content.Context -import android.content.Intent import android.net.Uri import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody +import it.vfsfitvnm.youtubemusic.requests.playlistPage +import it.vfsfitvnm.youtubemusic.utils.plus -fun Context.shareAsYouTubeSong(mediaItem: MediaItem) { - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaItem.mediaId}") - } - - startActivity(Intent.createChooser(sendIntent, null)) -} - -val YouTube.Item.Song.asMediaItem: MediaItem +val Innertube.SongItem.asMediaItem: MediaItem get() = MediaItem.Builder() - .setMediaId(info.endpoint!!.videoId!!) - .setUri(info.endpoint!!.videoId) - .setCustomCacheKey(info.endpoint!!.videoId) + .setMediaId(key) + .setUri(key) + .setCustomCacheKey(key) .setMediaMetadata( MediaMetadata.Builder() - .setTitle(info.name) - .setArtist(authors.joinToString("") { it.name }) + .setTitle(info?.name) + .setArtist(authors?.joinToString("") { it.name ?: "" }) .setAlbumTitle(album?.name) .setArtworkUri(thumbnail?.url?.toUri()) .setExtras( bundleOf( - "videoId" to info.endpoint!!.videoId, + "videoId" to key, "albumId" to album?.endpoint?.browseId, "durationText" to durationText, - "artistNames" to authors.filter { it.endpoint != null }.map { it.name }, - "artistIds" to authors.mapNotNull { it.endpoint?.browseId }, + "artistNames" to authors?.filter { it.endpoint != null }?.mapNotNull { it.name }, + "artistIds" to authors?.mapNotNull { it.endpoint?.browseId }, ) ) .build() ) .build() -val YouTube.Item.Video.asMediaItem: MediaItem +val Innertube.VideoItem.asMediaItem: MediaItem get() = MediaItem.Builder() - .setMediaId(info.endpoint!!.videoId!!) - .setUri(info.endpoint!!.videoId) - .setCustomCacheKey(info.endpoint!!.videoId) + .setMediaId(key) + .setUri(key) + .setCustomCacheKey(key) .setMediaMetadata( MediaMetadata.Builder() - .setTitle(info.name) - .setArtist(authors.joinToString("") { it.name }) + .setTitle(info?.name) + .setArtist(authors?.joinToString("") { it.name ?: "" }) .setArtworkUri(thumbnail?.url?.toUri()) .setExtras( bundleOf( - "videoId" to info.endpoint!!.videoId, + "videoId" to key, "durationText" to durationText, - "artistNames" to if (isOfficialMusicVideo) authors.filter { it.endpoint != null }.map { it.name } else null, - "artistIds" to if (isOfficialMusicVideo) authors.mapNotNull { it.endpoint?.browseId } else null, + "artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.mapNotNull { it.name } else null, + "artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } else null, ) ) .build() @@ -89,37 +80,6 @@ val DetailedSong.asMediaItem: MediaItem .setCustomCacheKey(id) .build() -fun YouTube.PlaylistOrAlbum.Item.toMediaItem( - albumId: String, - playlistOrAlbum: YouTube.PlaylistOrAlbum -): MediaItem? { - val isFromAlbum = thumbnail == null - - return MediaItem.Builder() - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(info.name) - .setArtist((authors ?: playlistOrAlbum.authors)?.joinToString("") { it.name }) - .setAlbumTitle(if (isFromAlbum) playlistOrAlbum.title else album?.name) - .setArtworkUri(if (isFromAlbum) playlistOrAlbum.thumbnail?.url?.toUri() else thumbnail?.url?.toUri()) - .setExtras( - bundleOf( - "videoId" to info.endpoint?.videoId, - "playlistId" to info.endpoint?.playlistId, - "albumId" to (if (isFromAlbum) albumId else album?.endpoint?.browseId), - "durationText" to durationText, - "artistNames" to (authors ?: playlistOrAlbum.authors)?.filter { it.endpoint != null }?.map { it.name }, - "artistIds" to (authors ?: playlistOrAlbum.authors)?.mapNotNull { it.endpoint?.browseId } - ) - ) - .build() - ) - .setMediaId(info.endpoint?.videoId ?: return null) - .setUri(info.endpoint?.videoId ?: return null) - .setCustomCacheKey(info.endpoint?.videoId ?: return null) - .build() -} - fun String?.thumbnail(size: Int): String? { return when { this?.startsWith("https://lh3.googleusercontent.com") == true -> "$this-w$size-h$size" @@ -131,3 +91,20 @@ fun String?.thumbnail(size: Int): String? { fun Uri?.thumbnail(size: Int): Uri? { return toString().thumbnail(size)?.toUri() } + +suspend fun Result.completed(): Result? { + var playlistPage = getOrNull() ?: return null + + while (playlistPage.songsPage?.continuation != null) { + val continuation = playlistPage.songsPage?.continuation!! + val otherPlaylistPageResult = Innertube.playlistPage(ContinuationBody(continuation = continuation)) ?: break + + if (otherPlaylistPageResult.isFailure) break + + otherPlaylistPageResult.getOrNull()?.let { otherSongsPage -> + playlistPage = playlistPage.copy(songsPage = playlistPage.songsPage + otherSongsPage) + } + } + + return Result.success(playlistPage) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt index 8a0ce11..a83399e 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt @@ -1,7 +1,10 @@ package it.vfsfitvnm.vimusic.utils import androidx.media3.common.MediaItem -import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody +import it.vfsfitvnm.youtubemusic.models.bodies.NextBody +import it.vfsfitvnm.youtubemusic.requests.nextPage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -17,20 +20,30 @@ data class YouTubeRadio( var mediaItems: List? = null nextContinuation = withContext(Dispatchers.IO) { - YouTube.next( - videoId = videoId, - playlistId = playlistId, - params = parameters, - playlistSetVideoId = playlistSetVideoId, - continuation = nextContinuation - )?.getOrNull()?.let { nextResult -> - playlistId = nextResult.playlistId - parameters = nextResult.params - playlistSetVideoId = nextResult.playlistSetVideoId + val continuation = nextContinuation - mediaItems = nextResult.items?.map(YouTube.Item.Song::asMediaItem) - nextResult.continuation?.takeUnless { nextContinuation == nextResult.continuation } + if (continuation == null) { + Innertube.nextPage( + NextBody( + videoId = videoId, + playlistId = playlistId, + params = parameters, + playlistSetVideoId = playlistSetVideoId + ) + )?.map { nextResult -> + playlistId = nextResult.playlistId + parameters = nextResult.params + playlistSetVideoId = nextResult.playlistSetVideoId + + nextResult.itemsPage + } + } else { + Innertube.nextPage(ContinuationBody(continuation = continuation)) + }?.getOrNull()?.let { songsPage -> + mediaItems = songsPage.items?.map(Innertube.SongItem::asMediaItem) + songsPage.continuation?.takeUnless { nextContinuation == it } } + } return mediaItems ?: emptyList() diff --git a/app/src/main/res/drawable/arrow_down.xml b/app/src/main/res/drawable/arrow_down.xml new file mode 100644 index 0000000..9d45330 --- /dev/null +++ b/app/src/main/res/drawable/arrow_down.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/arrow_up.xml b/app/src/main/res/drawable/arrow_up.xml new file mode 100644 index 0000000..9de10de --- /dev/null +++ b/app/src/main/res/drawable/arrow_up.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/bookmark.xml b/app/src/main/res/drawable/bookmark.xml new file mode 100644 index 0000000..416e06c --- /dev/null +++ b/app/src/main/res/drawable/bookmark.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bookmark_outline.xml b/app/src/main/res/drawable/bookmark_outline.xml new file mode 100644 index 0000000..1544145 --- /dev/null +++ b/app/src/main/res/drawable/bookmark_outline.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/calendar.xml b/app/src/main/res/drawable/calendar.xml new file mode 100644 index 0000000..3eb788b --- /dev/null +++ b/app/src/main/res/drawable/calendar.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/chevron_down.xml b/app/src/main/res/drawable/chevron_down.xml index 4504c11..41cdd90 100644 --- a/app/src/main/res/drawable/chevron_down.xml +++ b/app/src/main/res/drawable/chevron_down.xml @@ -1,6 +1,6 @@ + + diff --git a/app/src/main/res/drawable/chevron_up.xml b/app/src/main/res/drawable/chevron_up.xml new file mode 100644 index 0000000..257133c --- /dev/null +++ b/app/src/main/res/drawable/chevron_up.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/film.xml b/app/src/main/res/drawable/film.xml new file mode 100644 index 0000000..5e334a8 --- /dev/null +++ b/app/src/main/res/drawable/film.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/globe.xml b/app/src/main/res/drawable/globe.xml new file mode 100644 index 0000000..10a3b37 --- /dev/null +++ b/app/src/main/res/drawable/globe.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/library.xml b/app/src/main/res/drawable/library.xml new file mode 100644 index 0000000..1105723 --- /dev/null +++ b/app/src/main/res/drawable/library.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/medical.xml b/app/src/main/res/drawable/medical.xml new file mode 100644 index 0000000..aaba699 --- /dev/null +++ b/app/src/main/res/drawable/medical.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/musical_notes.xml b/app/src/main/res/drawable/musical_notes.xml new file mode 100644 index 0000000..ac341fb --- /dev/null +++ b/app/src/main/res/drawable/musical_notes.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/sad.xml b/app/src/main/res/drawable/sad.xml deleted file mode 100644 index c20d25b..0000000 --- a/app/src/main/res/drawable/sad.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/save.xml b/app/src/main/res/drawable/save.xml deleted file mode 100644 index 6f7453e..0000000 --- a/app/src/main/res/drawable/save.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/sparkles.xml b/app/src/main/res/drawable/sparkles.xml new file mode 100644 index 0000000..e0c6622 --- /dev/null +++ b/app/src/main/res/drawable/sparkles.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/star.xml b/app/src/main/res/drawable/star.xml new file mode 100644 index 0000000..6313be6 --- /dev/null +++ b/app/src/main/res/drawable/star.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/trending.xml b/app/src/main/res/drawable/trending.xml new file mode 100644 index 0000000..ed34e01 --- /dev/null +++ b/app/src/main/res/drawable/trending.xml @@ -0,0 +1,20 @@ + + + + diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyList.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyList.kt index b65df90..30514fd 100644 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyList.kt +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyList.kt @@ -25,14 +25,16 @@ import androidx.compose.foundation.lazy.LazyMeasuredItem import androidx.compose.foundation.lazy.LazyMeasuredItemProvider import androidx.compose.foundation.lazy.layout.LazyLayout import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope +import androidx.compose.foundation.lazy.layout.lazyLayoutSemantics import androidx.compose.foundation.lazy.lazyListBeyondBoundsModifier import androidx.compose.foundation.lazy.lazyListPinningModifier -import androidx.compose.foundation.lazy.lazyListSemantics import androidx.compose.foundation.lazy.measureLazyList import androidx.compose.foundation.lazy.rememberLazyListItemProvider +import androidx.compose.foundation.lazy.rememberLazyListSemanticState import androidx.compose.foundation.overscroll import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -62,16 +64,19 @@ internal fun ReorderingLazyList( ) { val overscrollEffect = ScrollableDefaults.overscrollEffect() val itemProvider = rememberLazyListItemProvider(reorderingState.lazyListState, content) + val semanticState = + rememberLazyListSemanticState(reorderingState.lazyListState, itemProvider, reverseLayout, isVertical) + val beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo + val scope = rememberCoroutineScope() val placementAnimator = remember(reorderingState.lazyListState, isVertical) { - LazyListItemPlacementAnimator(reorderingState.coroutineScope, isVertical).also { - reorderingState.lazyListState.placementAnimator = it - } + LazyListItemPlacementAnimator(scope, isVertical) } + reorderingState.lazyListState.placementAnimator = placementAnimator val measurePolicy = rememberLazyListMeasurePolicy( itemProvider, reorderingState.lazyListState, - reorderingState.lazyListBeyondBoundsInfo, + beyondBoundsInfo, overscrollEffect, contentPadding, reverseLayout, @@ -88,24 +93,15 @@ internal fun ReorderingLazyList( modifier = modifier .then(reorderingState.lazyListState.remeasurementModifier) .then(reorderingState.lazyListState.awaitLayoutModifier) - .lazyListSemantics( + .lazyLayoutSemantics( itemProvider = itemProvider, - state = reorderingState.lazyListState, - coroutineScope = reorderingState.coroutineScope, - isVertical = isVertical, - reverseScrolling = reverseLayout, + state = semanticState, + orientation = orientation, userScrollEnabled = userScrollEnabled ) .clipScrollableContainer(orientation) - .lazyListBeyondBoundsModifier( - state = reorderingState.lazyListState, - beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo, - reverseLayout = reverseLayout - ) - .lazyListPinningModifier( - state = reorderingState.lazyListState, - beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo - ) + .lazyListBeyondBoundsModifier(reorderingState.lazyListState, beyondBoundsInfo, reverseLayout) + .lazyListPinningModifier(reorderingState.lazyListState, beyondBoundsInfo) .overscroll(overscrollEffect) .scrollable( orientation = orientation, @@ -160,12 +156,10 @@ private fun rememberLazyListMeasurePolicy( if (isVertical) Orientation.Vertical else Orientation.Horizontal ) - // resolve content paddings val startPadding = if (isVertical) { contentPadding.calculateLeftPadding(layoutDirection).roundToPx() } else { - // in horizontal configuration, padding is reversed by placeRelative contentPadding.calculateStartPadding(layoutDirection).roundToPx() } @@ -173,7 +167,6 @@ private fun rememberLazyListMeasurePolicy( if (isVertical) { contentPadding.calculateRightPadding(layoutDirection).roundToPx() } else { - // in horizontal configuration, padding is reversed by placeRelative contentPadding.calculateEndPadding(layoutDirection).roundToPx() } val topPadding = contentPadding.calculateTopPadding().roundToPx() @@ -185,18 +178,18 @@ private fun rememberLazyListMeasurePolicy( isVertical && !reverseLayout -> topPadding isVertical && reverseLayout -> bottomPadding !isVertical && !reverseLayout -> startPadding - else -> endPadding // !isVertical && reverseLayout + else -> endPadding } val afterContentPadding = totalMainAxisPadding - beforeContentPadding val contentConstraints = containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) - // Update the state's cached Density state.density = this - // this will update the scope used by the item composables - itemProvider.itemScope.maxWidth = contentConstraints.maxWidth.toDp() - itemProvider.itemScope.maxHeight = contentConstraints.maxHeight.toDp() + itemProvider.itemScope.setMaxSize( + width = contentConstraints.maxWidth, + height = contentConstraints.maxHeight + ) val spaceBetweenItemsDp = if (isVertical) { requireNotNull(verticalArrangement).spacing @@ -207,7 +200,6 @@ private fun rememberLazyListMeasurePolicy( val itemsCount = itemProvider.itemCount - // can be negative if the content padding is larger than the max size from constraints val mainAxisAvailableSize = if (isVertical) { containerConstraints.maxHeight - totalVerticalPadding } else { @@ -216,9 +208,6 @@ private fun rememberLazyListMeasurePolicy( val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) { IntOffset(startPadding, topPadding) } else { - // When layout is reversed and paddings together take >100% of the available space, - // layout size is coerced to 0 when positioning. To take that space into account, - // we offset start padding by negative space between paddings. IntOffset( if (isVertical) startPadding else startPadding + mainAxisAvailableSize, if (isVertical) topPadding + mainAxisAvailableSize else topPadding @@ -231,8 +220,6 @@ private fun rememberLazyListMeasurePolicy( itemProvider, this ) { index, key, placeables -> - // we add spaceBetweenItems as an extra spacing for all items apart from the last one so - // the lazy list measuring logic will take it into account. val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems LazyMeasuredItem( index = index.value, @@ -265,6 +252,7 @@ private fun rememberLazyListMeasurePolicy( mainAxisAvailableSize = mainAxisAvailableSize, beforeContentPadding = beforeContentPadding, afterContentPadding = afterContentPadding, + spaceBetweenItems = spaceBetweenItems, firstVisibleItemIndex = firstVisibleItemIndex, firstVisibleItemScrollOffset = firstVisibleScrollOffset, scrollToBeConsumed = state.scrollToBeConsumed, diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt index 67d6208..ff8f22e 100644 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt @@ -52,11 +52,14 @@ class ReorderingState( internal var indexesToAnimate = mutableStateMapOf>() private var animatablesPool: AnimatablesPool? = null + val isDragging: Boolean + get() = draggingIndex != -1 + fun onDragStart(index: Int) { overscrolled = 0 itemInfo = lazyListState.layoutInfo.visibleItemsInfo.find { it.index == index + extraItemCount - }!! + } ?: return onDragStart.invoke() draggingIndex = index reachedIndex = index @@ -80,6 +83,7 @@ class ReorderingState( } fun onDrag(change: PointerInputChange, dragAmount: Offset) { + if (!isDragging) return change.consume() val delta = when (lazyListState.layoutInfo.orientation) { @@ -160,6 +164,8 @@ class ReorderingState( } fun onDragEnd() { + if (!isDragging) return + coroutineScope.launch { offset.animateTo((previousItemSize + nextItemSize) / 2) diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/GlobalRoute.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/GlobalRoute.kt new file mode 100644 index 0000000..00a17af --- /dev/null +++ b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/GlobalRoute.kt @@ -0,0 +1,14 @@ +package it.vfsfitvnm.route + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.flow.MutableSharedFlow + +internal val globalRouteFlow = MutableSharedFlow>>(extraBufferCapacity = 1) + +@Composable +fun OnGlobalRoute(block: suspend (Pair>) -> Unit) { + LaunchedEffect(Unit) { + globalRouteFlow.collect(block) + } +} diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Route.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Route.kt index 9fe83b2..992286d 100644 --- a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Route.kt +++ b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Route.kt @@ -4,10 +4,9 @@ package it.vfsfitvnm.route import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.runtime.saveable.rememberSaveable +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first @Immutable open class Route internal constructor(val tag: String) { @@ -23,23 +22,12 @@ open class Route internal constructor(val tag: String) { return tag.hashCode() } - object GlobalEmitter { - var listener: ((Route, Array) -> Unit)? = null - } - object Saver : androidx.compose.runtime.saveable.Saver { override fun restore(value: String): Route? = value.takeIf(String::isNotEmpty)?.let(::Route) override fun SaverScope.save(value: Route?): String = value?.tag ?: "" } } -@Composable -fun rememberRoute(route: Route? = null): MutableState { - return rememberSaveable(stateSaver = Route.Saver) { - mutableStateOf(route) - } -} - @Immutable class Route0(tag: String) : Route(tag) { context(RouteHandlerScope) @@ -51,7 +39,7 @@ class Route0(tag: String) : Route(tag) { } fun global() { - GlobalEmitter.listener?.invoke(this, emptyArray()) + globalRouteFlow.tryEmit(this to emptyArray()) } } @@ -66,7 +54,12 @@ class Route1(tag: String) : Route(tag) { } fun global(p0: P0) { - GlobalEmitter.listener?.invoke(this, arrayOf(p0)) + globalRouteFlow.tryEmit(this to arrayOf(p0)) + } + + suspend fun ensureGlobal(p0: P0) { + globalRouteFlow.subscriptionCount.filter { it > 0 }.first() + globalRouteFlow.emit(this to arrayOf(p0)) } } @@ -81,6 +74,6 @@ class Route2(tag: String) : Route(tag) { } fun global(p0: P0, p1: P1) { - GlobalEmitter.listener?.invoke(this, arrayOf(p0, p1)) + globalRouteFlow.tryEmit(this to arrayOf(p0, p1)) } } diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandler.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandler.kt index d7b432c..14032bc 100644 --- a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandler.kt +++ b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandler.kt @@ -8,8 +8,8 @@ import androidx.compose.animation.ContentTransform import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.updateTransition import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -21,10 +21,18 @@ fun RouteHandler( modifier: Modifier = Modifier, listenToGlobalEmitter: Boolean = false, handleBackPress: Boolean = true, - transitionSpec: AnimatedContentScope.() -> ContentTransform = { fastFade }, + transitionSpec: AnimatedContentScope.() -> ContentTransform = { + when { + isStacking -> defaultStacking + isStill -> defaultStill + else -> defaultUnstacking + } + }, content: @Composable RouteHandlerScope.() -> Unit ) { - var route by rememberRoute() + var route by rememberSaveable(stateSaver = Route.Saver) { + mutableStateOf(null) + } RouteHandler( route = route, @@ -45,7 +53,13 @@ fun RouteHandler( modifier: Modifier = Modifier, listenToGlobalEmitter: Boolean = false, handleBackPress: Boolean = true, - transitionSpec: AnimatedContentScope.() -> ContentTransform = { fastFade }, + transitionSpec: AnimatedContentScope.() -> ContentTransform = { + when { + isStacking -> defaultStacking + isStill -> defaultStill + else -> defaultUnstacking + } + }, content: @Composable RouteHandlerScope.() -> Unit ) { val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher @@ -63,12 +77,10 @@ fun RouteHandler( ) } - if (listenToGlobalEmitter) { - LaunchedEffect(route) { - Route.GlobalEmitter.listener = if (route == null) ({ newRoute, newParameters -> - newParameters.forEachIndexed(parameters::set) - onRouteChanged(newRoute) - }) else null + if (listenToGlobalEmitter && route == null) { + OnGlobalRoute { (newRoute, newParameters) -> + newParameters.forEachIndexed(parameters::set) + onRouteChanged(newRoute) } } diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Transitions.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Transitions.kt index b87cad0..79a54a1 100644 --- a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Transitions.kt +++ b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Transitions.kt @@ -3,27 +3,44 @@ package it.vfsfitvnm.route import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.with +import androidx.compose.animation.scaleOut @ExperimentalAnimationApi -val AnimatedContentScope.leftSlide: ContentTransform - get() = slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with - slideOutOfContainer(AnimatedContentScope.SlideDirection.Left) +val defaultStacking = ContentTransform( + initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(), + targetContentEnter = fadeIn(), + targetContentZIndex = 1f +) @ExperimentalAnimationApi -val AnimatedContentScope.rightSlide: ContentTransform - get() = slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with - slideOutOfContainer(AnimatedContentScope.SlideDirection.Right) +val defaultUnstacking = ContentTransform( + initialContentExit = fadeOut(), + targetContentEnter = EnterTransition.None, + targetContentZIndex = 0f +) @ExperimentalAnimationApi -val AnimatedContentScope.fastFade: ContentTransform - get() = fadeIn(tween(200)) with fadeOut(tween(200)) +val defaultStill = ContentTransform( + initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(), + targetContentEnter = fadeIn(), + targetContentZIndex = 1f +) @ExperimentalAnimationApi -val AnimatedContentScope.empty: ContentTransform - get() = EnterTransition.None with ExitTransition.None +inline val AnimatedContentScope.isStacking: Boolean + get() = initialState.route == null && targetState.route != null + +@ExperimentalAnimationApi +inline val AnimatedContentScope.isUnstacking: Boolean + get() = initialState.route != null && targetState.route == null + +@ExperimentalAnimationApi +inline val AnimatedContentScope.isStill: Boolean + get() = initialState.route == null && targetState.route == null + +@ExperimentalAnimationApi +inline val AnimatedContentScope.isUnknown: Boolean + get() = initialState.route != null && targetState.route != null diff --git a/fastlane/metadata/android/en-US/changelogs/16.txt b/fastlane/metadata/android/en-US/changelogs/16.txt new file mode 100644 index 0000000..a3c463c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/16.txt @@ -0,0 +1,10 @@ +* New user interface +* Added "Quick picks", "Albums" and "Artists" tabs to the home screen +* Added "Songs", "Albums" and "Singles" tabs to the artist screen +* Added "Other versions" tab to the album screen +* Albums and artists can be bookmarked +* Added "Library" tab to the search screen +* Removed the "loop none" option +* Imported playlists can now be synchronized +* Added the ability to open channel urls +* Opening a song url now automatically starts the playback diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 6d95d00..948c6df 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -3,6 +3,8 @@ * Search and play any song or video from YouTube Music * Background playback w/ notification * Search for songs, albums, artists, videos and playlists +* Bookmark artists and albums +* Import playlists * Automatic cache system for offline playback and saving resources * Fetch and edit lyrics and synchronized lyrics * Open YouTube/YouTube Music links diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg index 07abb0d..b664a00 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg index aa66f78..7e04a11 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg index 0815543..749ea53 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg index 97cdf47..88c54b7 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg index ba21f4a..17ce690 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg index 67aec81..7f05706 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg differ diff --git a/youtube-music/.gitignore b/innertube/.gitignore similarity index 100% rename from youtube-music/.gitignore rename to innertube/.gitignore diff --git a/youtube-music/build.gradle.kts b/innertube/build.gradle.kts similarity index 99% rename from youtube-music/build.gradle.kts rename to innertube/build.gradle.kts index dd07ad5..e2cf347 100644 --- a/youtube-music/build.gradle.kts +++ b/innertube/build.gradle.kts @@ -20,4 +20,4 @@ dependencies { implementation(libs.ktor.serialization.json) testImplementation(testLibs.junit) -} \ No newline at end of file +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt new file mode 100644 index 0000000..6370b30 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt @@ -0,0 +1,203 @@ +package it.vfsfitvnm.youtubemusic + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.BrowserUserAgent +import io.ktor.client.plugins.compression.ContentEncoding +import io.ktor.client.plugins.compression.brotli +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint +import it.vfsfitvnm.youtubemusic.models.Runs +import it.vfsfitvnm.youtubemusic.models.Thumbnail +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json + +object Innertube { + val client = HttpClient(OkHttp) { + BrowserUserAgent() + + expectSuccess = true + + install(ContentNegotiation) { + @OptIn(ExperimentalSerializationApi::class) + json(Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + }) + } + + install(ContentEncoding) { + brotli() + } + + defaultRequest { + url(scheme = "https", host ="music.youtube.com") { + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + headers.append("X-Goog-Api-Key", "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8") + parameters.append("prettyPrint", "false") + } + } + } + + internal const val browse = "/youtubei/v1/browse" + internal const val next = "/youtubei/v1/next" + internal const val player = "/youtubei/v1/player" + internal const val queue = "/youtubei/v1/music/get_queue" + internal const val search = "/youtubei/v1/search" + internal const val searchSuggestions = "/youtubei/v1/music/get_search_suggestions" + + internal const val musicResponsiveListItemRendererMask = "musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)" + internal const val musicTwoRowItemRendererMask = "musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)" + const val playlistPanelVideoRendererMask = "playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)" + + internal fun HttpRequestBuilder.mask(value: String = "*") = + header("X-Goog-FieldMask", value) + + data class Info( + val name: String?, + val endpoint: T? + ) { + @Suppress("UNCHECKED_CAST") + constructor(run: Runs.Run) : this( + name = run.text, + endpoint = run.navigationEndpoint?.endpoint as T? + ) + } + + @JvmInline + value class SearchFilter(val value: String) { + companion object { + val Song = SearchFilter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") + val Video = SearchFilter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D") + val Album = SearchFilter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D") + val Artist = SearchFilter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D") + val CommunityPlaylist = SearchFilter("EgeKAQQoAEABagoQAxAEEAoQCRAF") + val FeaturedPlaylist = SearchFilter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D") + } + } + + sealed class Item { + abstract val thumbnail: Thumbnail? + abstract val key: String + } + + data class SongItem( + val info: Info?, + val authors: List>?, + val album: Info?, + val durationText: String?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.videoId!! + + companion object + } + + data class VideoItem( + val info: Info?, + val authors: List>?, + val viewsText: String?, + val durationText: String?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.videoId!! + + val isOfficialMusicVideo: Boolean + get() = info + ?.endpoint + ?.watchEndpointMusicSupportedConfigs + ?.watchEndpointMusicConfig + ?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV" + + val isUserGeneratedContent: Boolean + get() = info + ?.endpoint + ?.watchEndpointMusicSupportedConfigs + ?.watchEndpointMusicConfig + ?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC" + + companion object + } + + data class AlbumItem( + val info: Info?, + val authors: List>?, + val year: String?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.browseId!! + + companion object + } + + data class ArtistItem( + val info: Info?, + val subscribersCountText: String?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.browseId!! + + companion object + } + + data class PlaylistItem( + val info: Info?, + val channel: Info?, + val songCount: Int?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.browseId!! + + companion object + } + + data class ArtistPage( + val name: String?, + val description: String?, + val thumbnail: Thumbnail?, + val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?, + val radioEndpoint: NavigationEndpoint.Endpoint.Watch?, + val songs: List?, + val songsEndpoint: NavigationEndpoint.Endpoint.Browse?, + val albums: List?, + val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?, + val singles: List?, + val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?, + ) + + data class PlaylistOrAlbumPage( + val title: String?, + val authors: List>?, + val year: String?, + val thumbnail: Thumbnail?, + val url: String?, + val songsPage: ItemsPage?, + val otherVersions: List? + ) + + data class NextPage( + val itemsPage: ItemsPage?, + val playlistId: String?, + val params: String? = null, + val playlistSetVideoId: String? = null + ) + + data class RelatedPage( + val songs: List? = null, + val playlists: List? = null, + val albums: List? = null, + val artists: List? = null, + ) + + data class ItemsPage( + val items: List?, + val continuation: String? + ) +} diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt similarity index 76% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt index a576205..3c4ada8 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt @@ -1,12 +1,10 @@ package it.vfsfitvnm.youtubemusic.models -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -@OptIn(ExperimentalSerializationApi::class) @Serializable data class BrowseResponse( - val contents: Contents, + val contents: Contents?, val header: Header?, val microformat: Microformat? ) { @@ -23,10 +21,10 @@ data class BrowseResponse( ) { @Serializable data class MusicDetailHeaderRenderer( - val title: Runs, - val subtitle: Runs, - val secondSubtitle: Runs, - val thumbnail: ThumbnailRenderer, + val title: Runs?, + val subtitle: Runs?, + val secondSubtitle: Runs?, + val thumbnail: ThumbnailRenderer?, ) @Serializable @@ -35,16 +33,16 @@ data class BrowseResponse( val playButton: PlayButton?, val startRadioButton: StartRadioButton?, val thumbnail: ThumbnailRenderer?, - val title: Runs + val title: Runs? ) { @Serializable data class PlayButton( - val buttonRenderer: ButtonRenderer + val buttonRenderer: ButtonRenderer? ) @Serializable data class StartRadioButton( - val buttonRenderer: ButtonRenderer + val buttonRenderer: ButtonRenderer? ) } } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ButtonRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ButtonRenderer.kt similarity index 72% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ButtonRenderer.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ButtonRenderer.kt index d511804..495cf83 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ButtonRenderer.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ButtonRenderer.kt @@ -4,5 +4,5 @@ import kotlinx.serialization.Serializable @Serializable data class ButtonRenderer( - val navigationEndpoint: NavigationEndpoint + val navigationEndpoint: NavigationEndpoint? ) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Context.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Context.kt new file mode 100644 index 0000000..92af0e8 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Context.kt @@ -0,0 +1,48 @@ +package it.vfsfitvnm.youtubemusic.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Context( + val client: Client, + val thirdParty: ThirdParty? = null, +) { + @Serializable + data class Client( + val clientName: String, + val clientVersion: String, + val visitorData: String?, + val hl: String = "en", + ) + + @Serializable + data class ThirdParty( + val embedUrl: String, + ) + + companion object { + val DefaultWeb = Context( + client = Client( + clientName = "WEB_REMIX", + clientVersion = "1.20220328.01.00", + visitorData = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D" + ) + ) + + val DefaultAndroid = Context( + client = Client( + clientName = "ANDROID", + clientVersion = "16.50", + visitorData = null, + ) + ) + + val DefaultAgeRestrictionBypass = Context( + client = Client( + clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + clientVersion = "2.0", + visitorData = null, + ) + ) + } +} diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt similarity index 84% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt index 4dbf917..5dbc6f8 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt @@ -8,10 +8,10 @@ import kotlinx.serialization.json.JsonNames @Serializable data class Continuation( @JsonNames("nextContinuationData", "nextRadioContinuationData") - val nextRadioContinuationData: Data + val nextContinuationData: Data? ) { @Serializable data class Data( - val continuation: String + val continuation: String? ) } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt similarity index 55% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt index abc48f8..f5df6c1 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt @@ -7,17 +7,12 @@ import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class ContinuationResponse( - val continuationContents: ContinuationContents, + val continuationContents: ContinuationContents?, ) { @Serializable data class ContinuationContents( @JsonNames("musicPlaylistShelfContinuation") - val musicShelfContinuation: MusicShelfRenderer? - ) { -// @Serializable -// data class MusicShelfContinuation( -// val continuations: List?, -// val contents: List -// ) - } + val musicShelfContinuation: MusicShelfRenderer?, + val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?, + ) } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetQueueResponse.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetQueueResponse.kt similarity index 75% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetQueueResponse.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetQueueResponse.kt index b9a7a44..3c619ba 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetQueueResponse.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetQueueResponse.kt @@ -1,9 +1,7 @@ package it.vfsfitvnm.youtubemusic.models -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -@OptIn(ExperimentalSerializationApi::class) @Serializable data class GetQueueResponse( val queueDatas: List?, diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GridRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GridRenderer.kt new file mode 100644 index 0000000..fce0477 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GridRenderer.kt @@ -0,0 +1,13 @@ +package it.vfsfitvnm.youtubemusic.models + +import kotlinx.serialization.Serializable + +@Serializable +data class GridRenderer( + val items: List?, +) { + @Serializable + data class Item( + val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? + ) +} diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt similarity index 71% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt index 995ecc2..e5ea54e 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt @@ -1,18 +1,16 @@ package it.vfsfitvnm.youtubemusic.models -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -@OptIn(ExperimentalSerializationApi::class) @Serializable data class MusicCarouselShelfRenderer( - val header: Header, - val contents: List, + val header: Header?, + val contents: List?, ) { @Serializable data class Content( val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, - val musicNavigationButtonRenderer: MusicNavigationButtonRenderer? + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, ) @Serializable @@ -24,11 +22,12 @@ data class MusicCarouselShelfRenderer( @Serializable data class MusicCarouselShelfBasicHeaderRenderer( val moreContentButton: MoreContentButton?, - val title: Runs, + val title: Runs?, + val strapline: Runs?, ) { @Serializable data class MoreContentButton( - val buttonRenderer: ButtonRenderer + val buttonRenderer: ButtonRenderer? ) } } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicResponsiveListItemRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicResponsiveListItemRenderer.kt similarity index 95% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicResponsiveListItemRenderer.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicResponsiveListItemRenderer.kt index 66016bc..340f5ba 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicResponsiveListItemRenderer.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicResponsiveListItemRenderer.kt @@ -15,7 +15,7 @@ data class MusicResponsiveListItemRenderer( @Serializable data class FlexColumn( @JsonNames("musicResponsiveListItemFixedColumnRenderer") - val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer + val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer? ) { @Serializable data class MusicResponsiveListItemFlexColumnRenderer( diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt similarity index 72% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt index 6bda074..25699d1 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt @@ -1,40 +1,38 @@ package it.vfsfitvnm.youtubemusic.models -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -@OptIn(ExperimentalSerializationApi::class) @Serializable data class MusicShelfRenderer( val bottomEndpoint: NavigationEndpoint?, - val contents: List, + val contents: List?, val continuations: List?, val title: Runs? ) { @Serializable data class Content( - val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer, + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, ) { val runs: Pair, List>> get() = (musicResponsiveListItemRenderer - .flexColumns - .firstOrNull() + ?.flexColumns + ?.firstOrNull() ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs ?: emptyList()) to (musicResponsiveListItemRenderer - .flexColumns - .lastOrNull() + ?.flexColumns + ?.lastOrNull() ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.splitBySeparator() ?: emptyList() ) - val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? + val thumbnail: Thumbnail? get() = musicResponsiveListItemRenderer - .thumbnail + ?.thumbnail ?.musicThumbnailRenderer ?.thumbnail ?.thumbnails diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicTwoRowItemRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicTwoRowItemRenderer.kt new file mode 100644 index 0000000..6baee0d --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicTwoRowItemRenderer.kt @@ -0,0 +1,11 @@ +package it.vfsfitvnm.youtubemusic.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicTwoRowItemRenderer( + val navigationEndpoint: NavigationEndpoint?, + val thumbnailRenderer: ThumbnailRenderer?, + val title: Runs?, + val subtitle: Runs?, +) diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt similarity index 96% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt index 54e115d..5352669 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt @@ -74,12 +74,12 @@ data class NavigationEndpoint( @Serializable data class WatchEndpointMusicSupportedConfigs( - val watchEndpointMusicConfig: WatchEndpointMusicConfig + val watchEndpointMusicConfig: WatchEndpointMusicConfig? ) { @Serializable data class WatchEndpointMusicConfig( - val musicVideoType: String + val musicVideoType: String? ) } } @@ -87,14 +87,14 @@ data class NavigationEndpoint( @Serializable data class WatchPlaylist( val params: String?, - val playlistId: String, + val playlistId: String?, ) : Endpoint() @Serializable data class Browse( - val params: String?, - val browseId: String, - val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?, + val params: String? = null, + val browseId: String? = null, + val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null, ) : Endpoint() { val type: String? get() = browseEndpointContextSupportedConfigs diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NextResponse.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NextResponse.kt similarity index 72% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NextResponse.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NextResponse.kt index 2dcdb28..7676c89 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NextResponse.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NextResponse.kt @@ -7,8 +7,7 @@ import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class NextResponse( - val contents: Contents, - val continuationContents: MusicQueueRenderer.Content? + val contents: Contents? ) { @Serializable data class MusicQueueRenderer( @@ -17,30 +16,18 @@ data class NextResponse( @Serializable data class Content( @JsonNames("playlistPanelContinuation") - val playlistPanelRenderer: PlaylistPanelRenderer + val playlistPanelRenderer: PlaylistPanelRenderer? ) { @Serializable data class PlaylistPanelRenderer( val contents: List?, val continuations: List?, - val playlistId: String? ) { @Serializable data class Content( val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?, val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer?, ) { - @Serializable - data class PlaylistPanelVideoRenderer( - val title: Runs?, - val longBylineText: Runs?, - val shortBylineText: Runs?, - val lengthText: Runs?, - val navigationEndpoint: NavigationEndpoint, - val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail, - val videoId: String, - val playlistSetVideoId: String?, - ) @Serializable data class AutomixPreviewVideoRenderer( @@ -52,7 +39,7 @@ data class NextResponse( ) { @Serializable data class AutomixPlaylistVideoRenderer( - val navigationEndpoint: NavigationEndpoint + val navigationEndpoint: NavigationEndpoint? ) } } @@ -63,33 +50,33 @@ data class NextResponse( @Serializable data class Contents( - val singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer + val singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer? ) { @Serializable data class SingleColumnMusicWatchNextResultsRenderer( - val tabbedRenderer: TabbedRenderer + val tabbedRenderer: TabbedRenderer? ) { @Serializable data class TabbedRenderer( - val watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer + val watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer? ) { @Serializable data class WatchNextTabbedResultsRenderer( - val tabs: List + val tabs: List? ) { @Serializable data class Tab( - val tabRenderer: TabRenderer + val tabRenderer: TabRenderer? ) { @Serializable data class TabRenderer( val content: Content?, val endpoint: NavigationEndpoint?, - val title: String + val title: String? ) { @Serializable data class Content( - val musicQueueRenderer: MusicQueueRenderer + val musicQueueRenderer: MusicQueueRenderer? ) } } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlayerResponse.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlayerResponse.kt similarity index 64% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlayerResponse.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlayerResponse.kt index fa8e760..18ffe4f 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlayerResponse.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlayerResponse.kt @@ -1,19 +1,16 @@ package it.vfsfitvnm.youtubemusic.models -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -@OptIn(ExperimentalSerializationApi::class) @Serializable data class PlayerResponse( - val playabilityStatus: PlayabilityStatus, + val playabilityStatus: PlayabilityStatus?, val playerConfig: PlayerConfig?, val streamingData: StreamingData?, - val videoDetails: VideoDetails?, ) { @Serializable data class PlayabilityStatus( - val status: String + val status: String? ) @Serializable @@ -29,8 +26,7 @@ data class PlayerResponse( @Serializable data class StreamingData( - val adaptiveFormats: List, - val expiresInSeconds: String + val adaptiveFormats: List? ) { @Serializable data class AdaptiveFormat( @@ -47,14 +43,4 @@ data class PlayerResponse( val url: String?, ) } - - @Serializable - data class VideoDetails( - val author: String, - val channelId: String, - val lengthSeconds: String, - val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail, - val title: String, - val videoId: String - ) } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlaylistPanelVideoRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlaylistPanelVideoRenderer.kt new file mode 100644 index 0000000..f24ba6d --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/PlaylistPanelVideoRenderer.kt @@ -0,0 +1,13 @@ +package it.vfsfitvnm.youtubemusic.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PlaylistPanelVideoRenderer( + val title: Runs?, + val longBylineText: Runs?, + val shortBylineText: Runs?, + val lengthText: Runs?, + val navigationEndpoint: NavigationEndpoint?, + val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail?, +) diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Runs.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Runs.kt similarity index 90% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Runs.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Runs.kt index 000dcb5..94825a8 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Runs.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Runs.kt @@ -7,7 +7,7 @@ data class Runs( val runs: List = listOf() ) { val text: String - get() = runs.joinToString("") { it.text } + get() = runs.joinToString("") { it.text ?: "" } fun splitBySeparator(): List> { return runs.flatMapIndexed { index, run -> @@ -25,7 +25,7 @@ data class Runs( @Serializable data class Run( - val text: String, + val text: String?, val navigationEndpoint: NavigationEndpoint?, ) } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchResponse.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchResponse.kt similarity index 66% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchResponse.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchResponse.kt index fbd4883..5d6936b 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchResponse.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchResponse.kt @@ -3,13 +3,12 @@ package it.vfsfitvnm.youtubemusic.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -@OptIn(ExperimentalSerializationApi::class) @Serializable data class SearchResponse( - val contents: Contents, + val contents: Contents?, ) { @Serializable data class Contents( - val tabbedSearchResultsRenderer: Tabs + val tabbedSearchResultsRenderer: Tabs? ) } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetSearchSuggestionsResponse.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchSuggestionsResponse.kt similarity index 78% rename from youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetSearchSuggestionsResponse.kt rename to innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchSuggestionsResponse.kt index 6741633..f6dc30a 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/GetSearchSuggestionsResponse.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SearchSuggestionsResponse.kt @@ -3,24 +3,24 @@ package it.vfsfitvnm.youtubemusic.models import kotlinx.serialization.Serializable @Serializable -data class GetSearchSuggestionsResponse( +data class SearchSuggestionsResponse( val contents: List? ) { @Serializable data class Content( - val searchSuggestionsSectionRenderer: SearchSuggestionsSectionRenderer + val searchSuggestionsSectionRenderer: SearchSuggestionsSectionRenderer? ) { @Serializable data class SearchSuggestionsSectionRenderer( - val contents: List + val contents: List? ) { @Serializable data class Content( - val searchSuggestionRenderer: SearchSuggestionRenderer + val searchSuggestionRenderer: SearchSuggestionRenderer? ) { @Serializable data class SearchSuggestionRenderer( - val navigationEndpoint: NavigationEndpoint, + val navigationEndpoint: NavigationEndpoint?, ) } } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SectionListRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SectionListRenderer.kt new file mode 100644 index 0000000..7b2bbfb --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/SectionListRenderer.kt @@ -0,0 +1,29 @@ +package it.vfsfitvnm.youtubemusic.models + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class SectionListRenderer( + val contents: List?, + val continuations: List? +) { + @Serializable + data class Content( + @JsonNames("musicImmersiveCarouselShelfRenderer") + val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?, + @JsonNames("musicPlaylistShelfRenderer") + val musicShelfRenderer: MusicShelfRenderer?, + val gridRenderer: GridRenderer?, + val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?, + ) { + + @Serializable + data class MusicDescriptionShelfRenderer( + val description: Runs?, + ) + } + +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt new file mode 100644 index 0000000..e6c5de5 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt @@ -0,0 +1,25 @@ +package it.vfsfitvnm.youtubemusic.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Tabs( + val tabs: List? +) { + @Serializable + data class Tab( + val tabRenderer: TabRenderer? + ) { + @Serializable + data class TabRenderer( + val content: Content?, + val title: String?, + val tabIdentifier: String?, + ) { + @Serializable + data class Content( + val sectionListRenderer: SectionListRenderer?, + ) + } + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Thumbnail.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Thumbnail.kt new file mode 100644 index 0000000..2705b99 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Thumbnail.kt @@ -0,0 +1,21 @@ +package it.vfsfitvnm.youtubemusic.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Thumbnail( + val url: String, + val height: Int?, + val width: Int? +) { + val isResizable: Boolean + get() = !url.startsWith("https://i.ytimg.com") + + fun size(size: Int): String { + return when { + url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size" + url.startsWith("https://yt3.ggpht.com") -> "$url-s$size" + else -> url + } + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ThumbnailRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ThumbnailRenderer.kt new file mode 100644 index 0000000..f1efcf5 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ThumbnailRenderer.kt @@ -0,0 +1,22 @@ +package it.vfsfitvnm.youtubemusic.models + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class ThumbnailRenderer( + @JsonNames("croppedSquareThumbnailRenderer") + val musicThumbnailRenderer: MusicThumbnailRenderer? +) { + @Serializable + data class MusicThumbnailRenderer( + val thumbnail: Thumbnail? + ) { + @Serializable + data class Thumbnail( + val thumbnails: List? + ) + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/BrowseBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/BrowseBody.kt new file mode 100644 index 0000000..ea910da --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/BrowseBody.kt @@ -0,0 +1,11 @@ +package it.vfsfitvnm.youtubemusic.models.bodies + +import it.vfsfitvnm.youtubemusic.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class BrowseBody( + val context: Context = Context.DefaultWeb, + val browseId: String, + val params: String? = null +) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/ContinuationBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/ContinuationBody.kt new file mode 100644 index 0000000..ff7b95b --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/ContinuationBody.kt @@ -0,0 +1,10 @@ +package it.vfsfitvnm.youtubemusic.models.bodies + +import it.vfsfitvnm.youtubemusic.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class ContinuationBody( + val context: Context = Context.DefaultWeb, + val continuation: String, +) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/NextBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/NextBody.kt new file mode 100644 index 0000000..f1face1 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/NextBody.kt @@ -0,0 +1,24 @@ +package it.vfsfitvnm.youtubemusic.models.bodies + +import it.vfsfitvnm.youtubemusic.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class NextBody( + val context: Context = Context.DefaultWeb, + val videoId: String?, + val isAudioOnly: Boolean = true, + val playlistId: String? = null, + val tunerSettingValue: String = "AUTOMIX_SETTING_NORMAL", + val index: Int? = null, + val params: String? = null, + val playlistSetVideoId: String? = null, + val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs = WatchEndpointMusicSupportedConfigs( + musicVideoType = "MUSIC_VIDEO_TYPE_ATV" + ) +) { + @Serializable + data class WatchEndpointMusicSupportedConfigs( + val musicVideoType: String + ) +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/PlayerBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/PlayerBody.kt new file mode 100644 index 0000000..6f9b11f --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/PlayerBody.kt @@ -0,0 +1,11 @@ +package it.vfsfitvnm.youtubemusic.models.bodies + +import it.vfsfitvnm.youtubemusic.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerBody( + val context: Context = Context.DefaultAndroid, + val videoId: String, + val playlistId: String? = null +) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/QueueBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/QueueBody.kt new file mode 100644 index 0000000..960dfbc --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/QueueBody.kt @@ -0,0 +1,11 @@ +package it.vfsfitvnm.youtubemusic.models.bodies + +import it.vfsfitvnm.youtubemusic.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class QueueBody( + val context: Context = Context.DefaultWeb, + val videoIds: List? = null, + val playlistId: String? = null, +) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/SearchBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/SearchBody.kt new file mode 100644 index 0000000..af4bb59 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/SearchBody.kt @@ -0,0 +1,11 @@ +package it.vfsfitvnm.youtubemusic.models.bodies + +import it.vfsfitvnm.youtubemusic.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class SearchBody( + val context: Context = Context.DefaultWeb, + val query: String, + val params: String +) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/SearchSuggestionsBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/SearchSuggestionsBody.kt new file mode 100644 index 0000000..3a7a32b --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/bodies/SearchSuggestionsBody.kt @@ -0,0 +1,10 @@ +package it.vfsfitvnm.youtubemusic.models.bodies + +import it.vfsfitvnm.youtubemusic.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class SearchSuggestionsBody( + val context: Context = Context.DefaultWeb, + val input: String +) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/AlbumPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/AlbumPage.kt new file mode 100644 index 0000000..0e610f6 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/AlbumPage.kt @@ -0,0 +1,36 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.http.Url +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody + +suspend fun Innertube.albumPage(body: BrowseBody): Result? { + return playlistPage(body)?.map { album -> + album.url?.let { Url(it).parameters["list"] }?.let { playlistId -> + playlistPage(BrowseBody(browseId = "VL$playlistId"))?.getOrNull()?.let { playlist -> + album.copy(songsPage = playlist.songsPage) + } + } ?: album + }?.map { album -> + val albumInfo = Innertube.Info( + name = album.title, + endpoint = NavigationEndpoint.Endpoint.Browse( + browseId = body.browseId, + params = body.params + ) + ) + + album.copy( + songsPage = album.songsPage?.copy( + items = album.songsPage.items?.map { song -> + song.copy( + authors = song.authors ?: album.authors, + album = albumInfo, + thumbnail = album.thumbnail + ) + } + ) + ) + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ArtistPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ArtistPage.kt new file mode 100644 index 0000000..c0f1ccb --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ArtistPage.kt @@ -0,0 +1,102 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.BrowseResponse +import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer +import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer +import it.vfsfitvnm.youtubemusic.models.SectionListRenderer +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.utils.findSectionByTitle +import it.vfsfitvnm.youtubemusic.utils.from +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + +suspend fun Innertube.artistPage(body: BrowseBody): Result? = runCatchingNonCancellable { + val response = client.post(browse) { + setBody(body) + mask("contents,header") + }.body() + + fun findSectionByTitle(text: String): SectionListRenderer.Content? { + return response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.get(0) + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.findSectionByTitle(text) + } + + val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer + val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer + val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer + + Innertube.ArtistPage( + name = response + .header + ?.musicImmersiveHeaderRenderer + ?.title + ?.text, + description = response + .header + ?.musicImmersiveHeaderRenderer + ?.description + ?.text + ?.substringBeforeLast("\n\nFrom Wikipedia"), + thumbnail = response + .header + ?.musicImmersiveHeaderRenderer + ?.thumbnail + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.getOrNull(0), + shuffleEndpoint = response + .header + ?.musicImmersiveHeaderRenderer + ?.playButton + ?.buttonRenderer + ?.navigationEndpoint + ?.watchEndpoint, + radioEndpoint = response + .header + ?.musicImmersiveHeaderRenderer + ?.startRadioButton + ?.buttonRenderer + ?.navigationEndpoint + ?.watchEndpoint, + songs = songsSection + ?.contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(Innertube.SongItem::from), + songsEndpoint = songsSection + ?.bottomEndpoint + ?.browseEndpoint, + albums = albumsSection + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + albumsEndpoint = albumsSection + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint, + singles = singlesSection + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + singlesEndpoint = singlesSection + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint, + ) +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ItemsPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ItemsPage.kt new file mode 100644 index 0000000..840aac0 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ItemsPage.kt @@ -0,0 +1,97 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.BrowseResponse +import it.vfsfitvnm.youtubemusic.models.ContinuationResponse +import it.vfsfitvnm.youtubemusic.models.GridRenderer +import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer +import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer +import it.vfsfitvnm.youtubemusic.models.MusicTwoRowItemRenderer +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + +suspend fun Innertube.itemsPage( + body: BrowseBody, + fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }, +) = runCatchingNonCancellable { + val response = client.post(browse) { + setBody(body) +// mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))") + }.body() + + val sectionListRendererContent = response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + ?.firstOrNull() + + itemsPageFromMusicShelRendererOrGridRenderer( + musicShelfRenderer = sectionListRendererContent + ?.musicShelfRenderer, + gridRenderer = sectionListRendererContent + ?.gridRenderer, + fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, + fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer, + ) +} + +suspend fun Innertube.itemsPage( + body: ContinuationBody, + fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }, +) = runCatchingNonCancellable { + val response = client.post(browse) { + setBody(body) +// mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))") + }.body() + + itemsPageFromMusicShelRendererOrGridRenderer( + musicShelfRenderer = response + .continuationContents + ?.musicShelfContinuation, + gridRenderer = null, + fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, + fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer, + ) +} + +private fun itemsPageFromMusicShelRendererOrGridRenderer( + musicShelfRenderer: MusicShelfRenderer?, + gridRenderer: GridRenderer?, + fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T?, + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T?, +): Innertube.ItemsPage? { + return if (musicShelfRenderer != null) { + Innertube.ItemsPage( + continuation = musicShelfRenderer + .continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation, + items = musicShelfRenderer + .contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(fromMusicResponsiveListItemRenderer) + ) + } else if (gridRenderer != null) { + Innertube.ItemsPage( + continuation = null, + items = gridRenderer + .items + ?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) + ?.mapNotNull(fromMusicTwoRowItemRenderer) + ) + } else { + null + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Lyrics.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Lyrics.kt new file mode 100644 index 0000000..a7222e9 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Lyrics.kt @@ -0,0 +1,44 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.BrowseResponse +import it.vfsfitvnm.youtubemusic.models.NextResponse +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.models.bodies.NextBody +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + +suspend fun Innertube.lyrics(body: NextBody): Result? = runCatchingNonCancellable { + val nextResponse = client.post(next) { + setBody(body) + mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)") + }.body() + + val browseId = nextResponse + .contents + ?.singleColumnMusicWatchNextResultsRenderer + ?.tabbedRenderer + ?.watchNextTabbedResultsRenderer + ?.tabs + ?.getOrNull(1) + ?.tabRenderer + ?.endpoint + ?.browseEndpoint + ?.browseId + ?: return@runCatchingNonCancellable null + + val response = client.post(browse) { + setBody(BrowseBody(browseId = browseId)) + mask("contents.sectionListRenderer.contents.musicDescriptionShelfRenderer.description") + }.body() + + response.contents + ?.sectionListRenderer + ?.contents + ?.firstOrNull() + ?.musicDescriptionShelfRenderer + ?.description + ?.text +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/NextPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/NextPage.kt new file mode 100644 index 0000000..38fed8a --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/NextPage.kt @@ -0,0 +1,90 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.ContinuationResponse +import it.vfsfitvnm.youtubemusic.models.NextResponse +import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody +import it.vfsfitvnm.youtubemusic.models.bodies.NextBody +import it.vfsfitvnm.youtubemusic.utils.from +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + + + +suspend fun Innertube.nextPage(body: NextBody): Result? = + runCatchingNonCancellable { + val response = client.post(next) { + setBody(body) + mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$playlistPanelVideoRendererMask))") + }.body() + + val tabs = response + .contents + ?.singleColumnMusicWatchNextResultsRenderer + ?.tabbedRenderer + ?.watchNextTabbedResultsRenderer + ?.tabs + + val playlistPanelRenderer = tabs + ?.getOrNull(0) + ?.tabRenderer + ?.content + ?.musicQueueRenderer + ?.content + ?.playlistPanelRenderer + + if (body.playlistId == null) { + val endpoint = playlistPanelRenderer + ?.contents + ?.lastOrNull() + ?.automixPreviewVideoRenderer + ?.content + ?.automixPlaylistVideoRenderer + ?.navigationEndpoint + ?.watchPlaylistEndpoint + + if (endpoint != null) { + return nextPage( + body.copy( + playlistId = endpoint.playlistId, + params = endpoint.params + ) + ) + } + } + + Innertube.NextPage( + playlistId = body.playlistId, + playlistSetVideoId = body.playlistSetVideoId, + params = body.params, + itemsPage = playlistPanelRenderer + ?.toSongsPage() + ) + } + +suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingNonCancellable { + val response = client.post(next) { + setBody(body) + mask("continuationContents.playlistPanelContinuation(continuations,contents.$playlistPanelVideoRendererMask)") + }.body() + + response + .continuationContents + ?.playlistPanelContinuation + ?.toSongsPage() +} + +private fun NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?.toSongsPage() = + Innertube.ItemsPage( + items = this + ?.contents + ?.mapNotNull(NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content::playlistPanelVideoRenderer) + ?.mapNotNull(Innertube.SongItem::from), + continuation = this + ?.continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation + ) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Player.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Player.kt new file mode 100644 index 0000000..c2554fd --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Player.kt @@ -0,0 +1,59 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.Context +import it.vfsfitvnm.youtubemusic.models.PlayerResponse +import it.vfsfitvnm.youtubemusic.models.bodies.PlayerBody +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable +import kotlinx.serialization.Serializable + +suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable { + val response = client.post(player) { + setBody(body) + mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats") + }.body() + + if (response.playabilityStatus?.status == "OK") { + response + } else { + @Serializable + data class AudioStream( + val url: String, + val bitrate: Long + ) + + @Serializable + data class PipedResponse( + val audioStreams: List + ) + + val safePlayerResponse = client.post(player) { + setBody(body.copy(context = Context.DefaultAgeRestrictionBypass)) + mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats") + }.body() + + if (safePlayerResponse.playabilityStatus?.status != "OK") { + return@runCatchingNonCancellable response + } + + val audioStreams = client.get("https://watchapi.whatever.social/streams/${body.videoId}") { + contentType(ContentType.Application.Json) + }.body().audioStreams + + safePlayerResponse.copy( + streamingData = safePlayerResponse.streamingData?.copy( + adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat -> + adaptiveFormat.copy( + url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url + ) + } + ) + ) + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/PlaylistPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/PlaylistPage.kt new file mode 100644 index 0000000..2c01ccb --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/PlaylistPage.kt @@ -0,0 +1,101 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.BrowseResponse +import it.vfsfitvnm.youtubemusic.models.ContinuationResponse +import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer +import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody +import it.vfsfitvnm.youtubemusic.utils.from +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + +suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable { + val response = client.post(browse) { + setBody(body) + mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),musicCarouselShelfRenderer.contents.$musicTwoRowItemRendererMask),header.musicDetailHeaderRenderer(title,subtitle,thumbnail),microformat") + }.body() + + val musicDetailHeaderRenderer = response + .header + ?.musicDetailHeaderRenderer + + val sectionListRendererContents = response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + + val musicShelfRenderer = sectionListRendererContents + ?.firstOrNull() + ?.musicShelfRenderer + + val musicCarouselShelfRenderer = sectionListRendererContents + ?.getOrNull(1) + ?.musicCarouselShelfRenderer + + Innertube.PlaylistOrAlbumPage( + title = musicDetailHeaderRenderer + ?.title + ?.text, + thumbnail = musicDetailHeaderRenderer + ?.thumbnail + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull(), + authors = musicDetailHeaderRenderer + ?.subtitle + ?.splitBySeparator() + ?.getOrNull(1) + ?.map(Innertube::Info), + year = musicDetailHeaderRenderer + ?.subtitle + ?.splitBySeparator() + ?.getOrNull(2) + ?.firstOrNull() + ?.text, + url = response + .microformat + ?.microformatDataRenderer + ?.urlCanonical, + songsPage = musicShelfRenderer + ?.toSongsPage(), + otherVersions = musicCarouselShelfRenderer + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from) + ) +} + +suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingNonCancellable { + val response = client.post(browse) { + setBody(body) + mask("continuationContents.musicPlaylistShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)") + }.body() + + response + .continuationContents + ?.musicShelfContinuation + ?.toSongsPage() +} + +private fun MusicShelfRenderer?.toSongsPage() = + Innertube.ItemsPage( + items = this + ?.contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(Innertube.SongItem::from), + continuation = this + ?.continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation + ) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Queue.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Queue.kt new file mode 100644 index 0000000..06c9f19 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/Queue.kt @@ -0,0 +1,29 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.GetQueueResponse +import it.vfsfitvnm.youtubemusic.models.bodies.QueueBody +import it.vfsfitvnm.youtubemusic.utils.from +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + +suspend fun Innertube.queue(body: QueueBody) = runCatchingNonCancellable { + val response = client.post(queue) { + setBody(body) + mask("queueDatas.content.$playlistPanelVideoRendererMask") + }.body() + + response + .queueDatas + ?.mapNotNull { queueData -> + queueData + .content + ?.playlistPanelVideoRenderer + ?.let(Innertube.SongItem::from) + } +} + +suspend fun Innertube.song(videoId: String): Result? = + queue(QueueBody(videoIds = listOf(videoId)))?.map { it?.firstOrNull() } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/RelatedPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/RelatedPage.kt new file mode 100644 index 0000000..f50f8cf --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/RelatedPage.kt @@ -0,0 +1,72 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.BrowseResponse +import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer +import it.vfsfitvnm.youtubemusic.models.NextResponse +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.models.bodies.NextBody +import it.vfsfitvnm.youtubemusic.utils.findSectionByStrapline +import it.vfsfitvnm.youtubemusic.utils.findSectionByTitle +import it.vfsfitvnm.youtubemusic.utils.from +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + +suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable { + val nextResponse = client.post(next) { + setBody(body) + mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)") + }.body() + + val browseId = nextResponse + .contents + ?.singleColumnMusicWatchNextResultsRenderer + ?.tabbedRenderer + ?.watchNextTabbedResultsRenderer + ?.tabs + ?.getOrNull(2) + ?.tabRenderer + ?.endpoint + ?.browseEndpoint + ?.browseId + ?: return@runCatchingNonCancellable null + + val response = client.post(browse) { + setBody(BrowseBody(browseId = browseId)) + mask("contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($musicResponsiveListItemRendererMask,$musicTwoRowItemRendererMask))") + }.body() + + val sectionListRenderer = response + .contents + ?.sectionListRenderer + + Innertube.RelatedPage( + songs = sectionListRenderer + ?.findSectionByTitle("You might also like") + ?.musicCarouselShelfRenderer + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(Innertube.SongItem::from), + playlists = sectionListRenderer + ?.findSectionByTitle("Recommended playlists") + ?.musicCarouselShelfRenderer + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.PlaylistItem::from) + ?.sortedByDescending { it.channel?.name == "YouTube Music" }, + albums = sectionListRenderer + ?.findSectionByStrapline("MORE FROM") + ?.musicCarouselShelfRenderer + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + artists = sectionListRenderer + ?.findSectionByTitle("Similar artists") + ?.musicCarouselShelfRenderer + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.ArtistItem::from), + ) +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/SearchPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/SearchPage.kt new file mode 100644 index 0000000..4d9218e --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/SearchPage.kt @@ -0,0 +1,62 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.ContinuationResponse +import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer +import it.vfsfitvnm.youtubemusic.models.SearchResponse +import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody +import it.vfsfitvnm.youtubemusic.models.bodies.SearchBody +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + +suspend fun Innertube.searchPage( + body: SearchBody, + fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? +) = runCatchingNonCancellable { + val response = client.post(search) { + setBody(body) + mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask)") + }.body() + + response + .contents + ?.tabbedSearchResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + ?.lastOrNull() + ?.musicShelfRenderer + ?.toItemsPage(fromMusicShelfRendererContent) +} + +suspend fun Innertube.searchPage( + body: ContinuationBody, + fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? +) = runCatchingNonCancellable { + val response = client.post(search) { + setBody(body) + mask("continuationContents.musicShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)") + }.body() + + response + .continuationContents + ?.musicShelfContinuation + ?.toItemsPage(fromMusicShelfRendererContent) +} + +private fun MusicShelfRenderer?.toItemsPage(mapper: (MusicShelfRenderer.Content) -> T?) = + Innertube.ItemsPage( + items = this + ?.contents + ?.mapNotNull(mapper), + continuation = this + ?.continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation + ) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/SearchSuggestions.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/SearchSuggestions.kt new file mode 100644 index 0000000..21a18bd --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/SearchSuggestions.kt @@ -0,0 +1,29 @@ +package it.vfsfitvnm.youtubemusic.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.SearchSuggestionsResponse +import it.vfsfitvnm.youtubemusic.models.bodies.SearchSuggestionsBody +import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable + +suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingNonCancellable { + val response = client.post(searchSuggestions) { + setBody(body) + mask("contents.searchSuggestionsSectionRenderer.contents.searchSuggestionRenderer.navigationEndpoint.searchEndpoint.query") + }.body() + + response + .contents + ?.firstOrNull() + ?.searchSuggestionsSectionRenderer + ?.contents + ?.mapNotNull { content -> + content + .searchSuggestionRenderer + ?.navigationEndpoint + ?.searchEndpoint + ?.query + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicResponsiveListItemRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicResponsiveListItemRenderer.kt new file mode 100644 index 0000000..51a3ca2 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicResponsiveListItemRenderer.kt @@ -0,0 +1,49 @@ +package it.vfsfitvnm.youtubemusic.utils + +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint +import it.vfsfitvnm.youtubemusic.models.Runs + +fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer): Innertube.SongItem? { + return Innertube.SongItem( + info = renderer + .flexColumns + .getOrNull(0) + ?.musicResponsiveListItemFlexColumnRenderer + ?.text + ?.runs + ?.getOrNull(0) + ?.let(Innertube::Info), + authors = renderer + .flexColumns + .getOrNull(1) + ?.musicResponsiveListItemFlexColumnRenderer + ?.text + ?.runs + ?.map>(Innertube::Info) + ?.takeIf(List::isNotEmpty), + durationText = renderer + .fixedColumns + ?.getOrNull(0) + ?.musicResponsiveListItemFlexColumnRenderer + ?.text + ?.runs + ?.getOrNull(0) + ?.text, + album = renderer + .flexColumns + .getOrNull(2) + ?.musicResponsiveListItemFlexColumnRenderer + ?.text + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + thumbnail = renderer + .thumbnail + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() + ).takeIf { it.info?.endpoint?.videoId != null } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicShelfRendererContent.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicShelfRendererContent.kt new file mode 100644 index 0000000..ea4c69b --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicShelfRendererContent.kt @@ -0,0 +1,140 @@ +package it.vfsfitvnm.youtubemusic.utils + +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint + +fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.SongItem? { + val (mainRuns, otherRuns) = content.runs + + // Possible configurations: + // "song" • author(s) • album • duration + // "song" • author(s) • duration + // author(s) • album • duration + // author(s) • duration + + val album: Innertube.Info? = otherRuns + .getOrNull(otherRuns.lastIndex - 1) + ?.firstOrNull() + ?.takeIf { run -> + run + .navigationEndpoint + ?.browseEndpoint + ?.type == "MUSIC_PAGE_TYPE_ALBUM" + } + ?.let(Innertube::Info) + + return Innertube.SongItem( + info = mainRuns + .firstOrNull() + ?.let(Innertube::Info), + authors = otherRuns + .getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2) + ?.map(Innertube::Info), + album = album, + durationText = otherRuns + .lastOrNull() + ?.firstOrNull()?.text, + thumbnail = content + .thumbnail + ).takeIf { it.info?.endpoint?.videoId != null } +} + +fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.VideoItem? { + val (mainRuns, otherRuns) = content.runs + + return Innertube.VideoItem( + info = mainRuns + .firstOrNull() + ?.let(Innertube::Info), + authors = otherRuns + .getOrNull(otherRuns.lastIndex - 2) + ?.map(Innertube::Info), + viewsText = otherRuns + .getOrNull(otherRuns.lastIndex - 1) + ?.firstOrNull() + ?.text, + durationText = otherRuns + .getOrNull(otherRuns.lastIndex) + ?.firstOrNull() + ?.text, + thumbnail = content + .thumbnail + ).takeIf { it.info?.endpoint?.videoId != null } +} + +fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.AlbumItem? { + val (mainRuns, otherRuns) = content.runs + + return Innertube.AlbumItem( + info = Innertube.Info( + name = mainRuns + .firstOrNull() + ?.text, + endpoint = content + .musicResponsiveListItemRenderer + ?.navigationEndpoint + ?.browseEndpoint + ), + authors = otherRuns + .getOrNull(otherRuns.lastIndex - 1) + ?.map(Innertube::Info), + year = otherRuns + .getOrNull(otherRuns.lastIndex) + ?.firstOrNull() + ?.text, + thumbnail = content + .thumbnail + ).takeIf { it.info?.endpoint?.browseId != null } +} + +fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.ArtistItem? { + val (mainRuns, otherRuns) = content.runs + + return Innertube.ArtistItem( + info = Innertube.Info( + name = mainRuns + .firstOrNull() + ?.text, + endpoint = content + .musicResponsiveListItemRenderer + ?.navigationEndpoint + ?.browseEndpoint + ), + subscribersCountText = otherRuns + .lastOrNull() + ?.last() + ?.text, + thumbnail = content + .thumbnail + ).takeIf { it.info?.endpoint?.browseId != null } +} + +fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.PlaylistItem? { + val (mainRuns, otherRuns) = content.runs + + return Innertube.PlaylistItem( + info = Innertube.Info( + name = mainRuns + .firstOrNull() + ?.text, + endpoint = content + .musicResponsiveListItemRenderer + ?.navigationEndpoint + ?.browseEndpoint + ), + channel = otherRuns + .firstOrNull() + ?.firstOrNull() + ?.let(Innertube::Info), + songCount = otherRuns + .lastOrNull() + ?.firstOrNull() + ?.text + ?.split(' ') + ?.firstOrNull() + ?.toIntOrNull(), + thumbnail = content + .thumbnail + ).takeIf { it.info?.endpoint?.browseId != null } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicTwoRowItemRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicTwoRowItemRenderer.kt new file mode 100644 index 0000000..f693427 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromMusicTwoRowItemRenderer.kt @@ -0,0 +1,76 @@ +package it.vfsfitvnm.youtubemusic.utils + +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.MusicTwoRowItemRenderer + +fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.AlbumItem? { + return Innertube.AlbumItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + authors = null, + year = renderer + .subtitle + ?.runs + ?.lastOrNull() + ?.text, + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() + ).takeIf { it.info?.endpoint?.browseId != null } +} + +fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.ArtistItem? { + return Innertube.ArtistItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + subscribersCountText = renderer + .subtitle + ?.runs + ?.firstOrNull() + ?.text, + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() + ).takeIf { it.info?.endpoint?.browseId != null } +} + +fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.PlaylistItem? { + return Innertube.PlaylistItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + channel = renderer + .subtitle + ?.runs + ?.getOrNull(2) + ?.let(Innertube::Info), + songCount = renderer + .subtitle + ?.runs + ?.getOrNull(4) + ?.text + ?.split(' ') + ?.firstOrNull() + ?.toIntOrNull(), + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() + ).takeIf { it.info?.endpoint?.browseId != null } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromPlaylistPanelVideoRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromPlaylistPanelVideoRenderer.kt new file mode 100644 index 0000000..d180dfe --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/FromPlaylistPanelVideoRenderer.kt @@ -0,0 +1,35 @@ +package it.vfsfitvnm.youtubemusic.utils + +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.PlaylistPanelVideoRenderer + +fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer): Innertube.SongItem? { + return Innertube.SongItem( + info = Innertube.Info( + name = renderer + .title + ?.text, + endpoint = renderer + .navigationEndpoint + ?.watchEndpoint + ), + authors = renderer + .longBylineText + ?.splitBySeparator() + ?.getOrNull(0) + ?.map(Innertube::Info), + album = renderer + .longBylineText + ?.splitBySeparator() + ?.getOrNull(1) + ?.getOrNull(0) + ?.let(Innertube::Info), + thumbnail = renderer + .thumbnail + ?.thumbnails + ?.getOrNull(0), + durationText = renderer + .lengthText + ?.text + ).takeIf { it.info?.endpoint?.videoId != null } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/Utils.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/Utils.kt new file mode 100644 index 0000000..3f1dd23 --- /dev/null +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/utils/Utils.kt @@ -0,0 +1,50 @@ +package it.vfsfitvnm.youtubemusic.utils + +import io.ktor.utils.io.CancellationException +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.SectionListRenderer + +internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? { + return contents?.find { content -> + val title = content + .musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.title + ?: content + .musicShelfRenderer + ?.title + + title + ?.runs + ?.firstOrNull() + ?.text == text + } +} + +internal fun SectionListRenderer.findSectionByStrapline(text: String): SectionListRenderer.Content? { + return contents?.find { content -> + content + .musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.strapline + ?.runs + ?.firstOrNull() + ?.text == text + } +} + +internal inline fun runCatchingNonCancellable(block: () -> R): Result? { + val result = runCatching(block) + return when (result.exceptionOrNull()) { + is CancellationException -> null + else -> result + } +} + +infix operator fun Innertube.ItemsPage?.plus(other: Innertube.ItemsPage) = + other.copy( + items = this?.items?.plus(other.items ?: emptyList())?.distinctBy(Innertube.Item::key) + ?: other.items + ) diff --git a/youtube-music/src/test/kotlin/Test.kt b/innertube/src/test/kotlin/Test.kt similarity index 100% rename from youtube-music/src/test/kotlin/Test.kt rename to innertube/src/test/kotlin/Test.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 6c9737d..34f6f40 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,18 +6,19 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { setUrl("https://jitpack.io") } } versionCatalogs { create("libs") { - version("kotlin", "1.7.10") + version("kotlin", "1.7.20") plugin("kotlin-serialization","org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") library("kotlin-coroutines","org.jetbrains.kotlinx", "kotlinx-coroutines-core").version("1.6.4") - version("compose-compiler", "1.3.0") + version("compose-compiler", "1.3.2") - version("compose", "1.3.0-alpha03") + version("compose", "1.3.0-rc01") library("compose-foundation", "androidx.compose.foundation", "foundation").versionRef("compose") library("compose-ui", "androidx.compose.ui", "ui").versionRef("compose") library("compose-ui-util", "androidx.compose.ui", "ui-util").versionRef("compose") @@ -61,6 +62,6 @@ rootProject.name = "ViMusic" include(":app") include(":compose-routing") include(":compose-reordering") -include(":youtube-music") +include(":innertube") include(":ktor-client-brotli") include(":kugou") diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Result.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Result.kt deleted file mode 100644 index 8524ce7..0000000 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Result.kt +++ /dev/null @@ -1,10 +0,0 @@ -package it.vfsfitvnm.youtubemusic - -import io.ktor.utils.io.CancellationException - -internal fun Result.recoverIfCancelled(): Result? { - return when (exceptionOrNull()) { - is CancellationException -> null - else -> this - } -} diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt deleted file mode 100644 index c24f77b..0000000 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt +++ /dev/null @@ -1,1018 +0,0 @@ -package it.vfsfitvnm.youtubemusic - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.BrowserUserAgent -import io.ktor.client.plugins.compression.ContentEncoding -import io.ktor.client.plugins.compression.brotli -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.Url -import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json -import it.vfsfitvnm.youtubemusic.models.BrowseResponse -import it.vfsfitvnm.youtubemusic.models.ContinuationResponse -import it.vfsfitvnm.youtubemusic.models.GetQueueResponse -import it.vfsfitvnm.youtubemusic.models.GetSearchSuggestionsResponse -import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer -import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer -import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint -import it.vfsfitvnm.youtubemusic.models.NextResponse -import it.vfsfitvnm.youtubemusic.models.PlayerResponse -import it.vfsfitvnm.youtubemusic.models.Runs -import it.vfsfitvnm.youtubemusic.models.SearchResponse -import it.vfsfitvnm.youtubemusic.models.ThumbnailRenderer -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -@OptIn(ExperimentalSerializationApi::class) -object YouTube { - private const val Key = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" - - val client = HttpClient(OkHttp) { - BrowserUserAgent() - - expectSuccess = true - - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - explicitNulls = false - encodeDefaults = true - }) - } - - install(ContentEncoding) { - brotli() - } - - defaultRequest { - url("https://music.youtube.com") - } - } - - @Serializable - data class EmptyBody( - val context: Context, - ) - - @Serializable - data class BrowseBody( - val context: Context, - val browseId: String, - ) - - @Serializable - data class SearchBody( - val context: Context, - val query: String, - val params: String - ) - - @Serializable - data class PlayerBody( - val context: Context, - val videoId: String, - val playlistId: String? - ) - - @Serializable - data class GetQueueBody( - val context: Context, - val videoIds: List?, - val playlistId: String?, - ) - - @Serializable - data class NextBody( - val context: Context, - val isAudioOnly: Boolean, - val videoId: String?, - val playlistId: String?, - val tunerSettingValue: String, - val index: Int?, - val params: String?, - val playlistSetVideoId: String?, - val continuation: String?, - val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs - ) { - @Serializable - data class WatchEndpointMusicSupportedConfigs( - val musicVideoType: String - ) - } - - @Serializable - data class GetSearchSuggestionsBody( - val context: Context, - val input: String - ) - - @Serializable - data class Context( - val client: Client, - val thirdParty: ThirdParty? = null, - ) { - @Serializable - data class Client( - val clientName: String, - val clientVersion: String, - val visitorData: String?, -// val gl: String = "US", - val hl: String = "en", - ) - - @Serializable - data class ThirdParty( - val embedUrl: String, - ) - - companion object { - val DefaultWeb = Context( - client = Client( - clientName = "WEB_REMIX", - clientVersion = "1.20220328.01.00", - visitorData = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D" - ) - ) - - val DefaultAndroid = Context( - client = Client( - clientName = "ANDROID", - clientVersion = "16.50", - visitorData = null, - ) - ) - - val DefaultAgeRestrictionBypass = Context( - client = Client( - clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", - clientVersion = "2.0", - visitorData = null, - ) - ) - } - } - - data class Info( - val name: String, - val endpoint: T? - ) { - companion object { - inline fun from(run: Runs.Run): Info { - return Info( - name = run.text, - endpoint = run.navigationEndpoint?.endpoint as T? - ) - } - } - } - - sealed class Item { - abstract val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? - - data class Song( - val info: Info, - val authors: List>, - val album: Info?, - val durationText: String?, - override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? - ) : Item() { - companion object : FromMusicShelfRendererContent { - val Filter = Filter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") - - override fun from(content: MusicShelfRenderer.Content): Song { - val (mainRuns, otherRuns) = content.runs - - // Possible configurations: - // "song" • author(s) • album • duration - // "song" • author(s) • duration - // author(s) • album • duration - // author(s) • duration - - val album: Info? = otherRuns - .getOrNull(otherRuns.lastIndex - 1) - ?.firstOrNull() - ?.takeIf { run -> - run - .navigationEndpoint - ?.browseEndpoint - ?.type == "MUSIC_PAGE_TYPE_ALBUM" - } - ?.let(Info.Companion::from) - - return Song( - info = Info.from(mainRuns.first()), - authors = otherRuns - .getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2) - ?.map(Info.Companion::from) - ?: emptyList(), - album = album, - durationText = otherRuns - .lastOrNull() - ?.firstOrNull()?.text, - thumbnail = content - .thumbnail - ) - } - } - } - - data class Video( - val info: Info, - val authors: List>, - val views: List>, - val durationText: String?, - override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? - ) : Item() { - val isOfficialMusicVideo: Boolean - get() = info - .endpoint - ?.watchEndpointMusicSupportedConfigs - ?.watchEndpointMusicConfig - ?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV" - - val isUserGeneratedContent: Boolean - get() = info - .endpoint - ?.watchEndpointMusicSupportedConfigs - ?.watchEndpointMusicConfig - ?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC" - - companion object : FromMusicShelfRendererContent