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