From f463f82f7297591a4254d747d7119497f33fc879 Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Sat, 24 Sep 2022 20:18:43 +0200 Subject: [PATCH] Redesign AlbumScreen (#172) --- .../18.json | 602 ++++++++++++++++++ .../kotlin/it/vfsfitvnm/vimusic/Database.kt | 16 +- .../vimusic/models/AlbumWithSongs.kt | 22 + .../vimusic/models/SortedSongAlbumMap.kt | 13 + .../vimusic/ui/components/themed/Header.kt | 34 + .../vimusic/ui/components/themed/Scaffold.kt | 11 +- .../ui/components/themed/VerticalBar.kt | 73 +-- .../vimusic/ui/screens/AlbumScreen.kt | 421 ------------ .../it/vfsfitvnm/vimusic/ui/screens/Routes.kt | 1 + .../vimusic/ui/screens/album/AlbumScreen.kt | 27 + .../vimusic/ui/screens/album/AlbumSongList.kt | 276 ++++++++ .../ui/screens/album/AlbumViewModel.kt | 66 ++ .../vimusic/ui/styling/Dimensions.kt | 2 + .../it/vfsfitvnm/vimusic/ui/views/SongItem.kt | 2 + 14 files changed, 1066 insertions(+), 500 deletions(-) create mode 100644 app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/models/AlbumWithSongs.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongAlbumMap.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongList.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumViewModel.kt 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..00613b9 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json @@ -0,0 +1,602 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "dec162db7ec49f4324481d54c49a793d", + "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, 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 + } + ], + "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, 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 + } + ], + "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" + }, + { + "viewName": "SortedSongAlbumMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongAlbumMap 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, 'dec162db7ec49f4324481d54c49a793d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 3352650..e83fbfd 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -32,6 +32,7 @@ import it.vfsfitvnm.vimusic.enums.PlaylistSortBy import it.vfsfitvnm.vimusic.enums.SongSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.Album +import it.vfsfitvnm.vimusic.models.AlbumWithSongs import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.models.DetailedSong import it.vfsfitvnm.vimusic.models.DetailedSongWithContentLength @@ -45,6 +46,7 @@ import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.SongAlbumMap import it.vfsfitvnm.vimusic.models.SongArtistMap import it.vfsfitvnm.vimusic.models.SongPlaylistMap +import it.vfsfitvnm.vimusic.models.SortedSongAlbumMap import it.vfsfitvnm.vimusic.models.SortedSongPlaylistMap import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -144,8 +146,9 @@ interface Database { @Query("SELECT * FROM Artist WHERE id = :id") fun artist(id: String): Flow + @Transaction @Query("SELECT * FROM Album WHERE id = :id") - fun album(id: String): Flow + fun albumWithSongs(id: String): Flow @Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id") fun incrementTotalPlayTimeMs(id: String, addition: Long) @@ -190,11 +193,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 @@ -361,9 +359,10 @@ interface Database { Format::class, ], views = [ - SortedSongPlaylistMap::class + SortedSongPlaylistMap::class, + SortedSongAlbumMap::class ], - version = 17, + version = 18, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -379,6 +378,7 @@ interface Database { AutoMigration(from = 13, to = 14), AutoMigration(from = 15, to = 16), AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/AlbumWithSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/AlbumWithSongs.kt new file mode 100644 index 0000000..0ca36e5 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/AlbumWithSongs.kt @@ -0,0 +1,22 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class AlbumWithSongs( + @Embedded val album: Album, + @Relation( + entity = Song::class, + parentColumn = "id", + entityColumn = "id", + associateBy = Junction( + value = SortedSongAlbumMap::class, + parentColumn = "albumId", + entityColumn = "songId" + ) + ) + val songs: List +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongAlbumMap.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongAlbumMap.kt new file mode 100644 index 0000000..b41b451 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongAlbumMap.kt @@ -0,0 +1,13 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@Immutable +@DatabaseView("SELECT * FROM SongAlbumMap ORDER BY position") +data class SortedSongAlbumMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val albumId: String, + val position: Int +) 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 index bdcc125..c174126 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -11,12 +13,15 @@ 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.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.medium +import kotlin.random.Random @Composable fun Header( @@ -69,3 +74,32 @@ fun Header( ) } } + +@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/Scaffold.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt index ed4a95a..1a951fa 100644 --- 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 @@ -112,16 +112,11 @@ fun Scaffold( } } -@SuppressLint("ModifierParameter") @ExperimentalAnimationApi @Composable fun SimpleScaffold( topIconButtonId: Int, onTopIconButtonClick: () -> Unit, - title: String = "", - primaryIconButtonId: Int? = null, - primaryIconButtonEnabled: Boolean = true, - onPrimaryIconButtonClick: () -> Unit = {}, modifier: Modifier = Modifier, content: @Composable () -> Unit ) { @@ -132,13 +127,9 @@ fun SimpleScaffold( .background(colorPalette.background0) .fillMaxSize() ) { - VerticalTitleBar( + VerticalBar( topIconButtonId = topIconButtonId, onTopIconButtonClick = onTopIconButtonClick, - title = title, - primaryIconButtonId = primaryIconButtonId, - primaryIconButtonEnabled = primaryIconButtonEnabled, - onPrimaryIconButtonClick = onPrimaryIconButtonClick, modifier = Modifier .padding(LocalPlayerAwarePaddingValues.current) ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/VerticalBar.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/VerticalBar.kt index d37a256..c21cf51 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/VerticalBar.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/VerticalBar.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.components.TabColumn import it.vfsfitvnm.vimusic.ui.components.vertical +import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.semiBold @@ -35,8 +36,6 @@ fun VerticalBar( tabIndex: Int, onTabChanged: (Int) -> Unit, tabColumnContent: @Composable (@Composable (Int, String, Int) -> Unit) -> Unit, -// primaryIconButtonId: Int? = null, -// onPrimaryIconButtonClick: () -> Unit = {}, modifier: Modifier = Modifier ) { val (colorPalette, typography) = LocalAppearance.current @@ -44,6 +43,7 @@ fun VerticalBar( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier +// .width(Dimensions.verticalBarWidth) .padding(vertical = 16.dp) ) { // Box( @@ -68,7 +68,7 @@ fun VerticalBar( Spacer( modifier = Modifier - .width(64.dp) + .width(Dimensions.verticalBarWidth) .height(32.dp) ) @@ -110,77 +110,28 @@ fun VerticalBar( @SuppressLint("ModifierParameter") @Composable -fun VerticalTitleBar( +fun VerticalBar( topIconButtonId: Int, onTopIconButtonClick: () -> Unit, - title: String, - primaryIconButtonId: Int? = null, - primaryIconButtonEnabled: Boolean = true, - onPrimaryIconButtonClick: () -> Unit = {}, modifier: Modifier = Modifier ) { - val (colorPalette, typography) = LocalAppearance.current + val (colorPalette) = LocalAppearance.current Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier + .width(Dimensions.verticalBarWidth) .padding(vertical = 16.dp) ) { - Box( + Image( + painter = painterResource(topIconButtonId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textSecondary), modifier = Modifier .clip(RoundedCornerShape(16.dp)) .clickable(onClick = onTopIconButtonClick) - .background(color = colorPalette.background1) - .size(48.dp) - ) { - Image( - painter = painterResource(topIconButtonId), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textSecondary), - modifier = Modifier - .align(Alignment.Center) - .size(22.dp) - ) - } - - Spacer( - modifier = Modifier - .width(78.dp) - .height(32.dp) + .padding(all = 12.dp) + .size(22.dp) ) - - BasicText( - text = title, - style = typography.m.semiBold, - modifier = Modifier - .vertical() - .rotate(-90f) - .padding(horizontal = 16.dp) - ) - - Spacer( - modifier = Modifier - .weight(1f) - ) - - primaryIconButtonId?.let { - Box( - modifier = Modifier - .offset(x = 8.dp) - .clip(RoundedCornerShape(16.dp)) - .clickable(enabled = primaryIconButtonEnabled, onClick = onPrimaryIconButtonClick) - .background(colorPalette.background1) - .size(62.dp) - ) { - Image( - painter = painterResource(primaryIconButtonId), - 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/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/Routes.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt index 593b9cd..d41a827 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 @@ -8,6 +8,7 @@ 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 val albumRoute = Route1("albumRoute") val artistRoute = Route1("artistRoute") 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..a10d6b9 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt @@ -0,0 +1,27 @@ +package it.vfsfitvnm.vimusic.ui.screens.album + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.themed.SimpleScaffold +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes + +@OptIn(ExperimentalFoundationApi::class) +@ExperimentalAnimationApi +@Composable +fun AlbumScreen(browseId: String) { + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + SimpleScaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + ) { + AlbumSongList(browseId = browseId) + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongList.kt new file mode 100644 index 0000000..08e442e --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongList.kt @@ -0,0 +1,276 @@ +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.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.BoxWithConstraints +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.pointer.pointerInput +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.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.valentinilk.shimmer.shimmer +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.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder +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.center +import it.vfsfitvnm.vimusic.utils.enqueue +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.thumbnail + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@Composable +fun AlbumSongList( + browseId: String, + viewModel: AlbumViewModel = viewModel( + key = browseId, + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return AlbumViewModel(browseId) as T + } + } + ) +) { + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val context = LocalContext.current + + BoxWithConstraints { + val thumbnailSizeDp = maxWidth - Dimensions.verticalBarWidth + val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px + + viewModel.result?.getOrNull()?.let { albumWithSongs -> + LazyColumn( + contentPadding = LocalPlayerAwarePaddingValues.current, + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column { + Header(title = albumWithSongs.album.title ?: "Unknown") { + if (albumWithSongs.songs.isNotEmpty()) { + BasicText( + text = "Enqueue", + style = typography.xxs.medium, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable { + binder?.player?.enqueue( + albumWithSongs.songs.map(DetailedSong::asMediaItem) + ) + } + .background(colorPalette.background2) + .padding(all = 8.dp) + .padding(horizontal = 8.dp) + ) + } + + Spacer( + modifier = Modifier + .weight(1f) + ) + + Image( + painter = painterResource(R.drawable.share_social), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + albumWithSongs.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 + ) + ) + } + } + .padding(all = 4.dp) + .size(18.dp) + ) + } + + AsyncImage( + model = albumWithSongs.album.thumbnailUrl?.thumbnail(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + .clip(thumbnailShape) + .size(thumbnailSizeDp) + ) + } + } + + itemsIndexed( + items = albumWithSongs.songs, + key = { _, song -> song.id } + ) { index, song -> + SongItem( + title = song.title, + authors = song.artistsText ?: albumWithSongs.album.authorsText, + durationText = song.durationText, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + albumWithSongs.songs.map(DetailedSong::asMediaItem), + index + ) + }, + startContent = { + BasicText( + text = "${index + 1}", + style = typography.m.secondary.secondary.center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(Dimensions.thumbnails.song) + ) + }, + menuContent = { + NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) + } + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(all = 16.dp) + .padding(LocalPlayerAwarePaddingValues.current) + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = albumWithSongs.songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + albumWithSongs.songs + .shuffled() + .map(DetailedSong::asMediaItem) + ) + } + .background(colorPalette.background2) + .size(62.dp) + ) { + Image( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .align(Alignment.Center) + .size(20.dp) + ) + } + } ?: viewModel.result?.exceptionOrNull()?.let { _ -> + Box( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { + viewModel.fetch(browseId) + } + } + .align(Alignment.Center) + .fillMaxSize() + ) { + BasicText( + text = "An error has occurred.\nTap to retry", + style = typography.s.medium.secondary.center, + modifier = Modifier + .align(Alignment.Center) + ) + } + } ?: Column( + modifier = Modifier + .padding(LocalPlayerAwarePaddingValues.current) + .shimmer() + ) { + HeaderPlaceholder() + + Spacer( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + .clip(thumbnailShape) + .size(thumbnailSizeDp) + .background(colorPalette.shimmer) + ) + + repeat(3) { index -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .alpha(1f - index * 0.25f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = Dimensions.itemsVerticalPadding) + .height(Dimensions.thumbnails.song) + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(Dimensions.thumbnails.song) + ) + + Column { + TextPlaceholder() + TextPlaceholder() + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumViewModel.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumViewModel.kt new file mode 100644 index 0000000..3e6b3fe --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumViewModel.kt @@ -0,0 +1,66 @@ +package it.vfsfitvnm.vimusic.ui.screens.album + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.models.Album +import it.vfsfitvnm.vimusic.models.AlbumWithSongs +import it.vfsfitvnm.vimusic.models.SongAlbumMap +import it.vfsfitvnm.vimusic.utils.toMediaItem +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class AlbumViewModel(browseId: String) : ViewModel() { + var result by mutableStateOf?>(null) + private set + + private var job: Job? = null + + init { + fetch(browseId) + } + + fun fetch(browseId: String) { + job?.cancel() + result = null + + job = viewModelScope.launch(Dispatchers.IO) { + Database.albumWithSongs(browseId).collect { albumWithSongs -> + result = if (albumWithSongs?.album?.timestamp == null) { + 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 + } + } else { + Result.success(albumWithSongs) + } + } + } + } +} 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..f503f5b 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,8 @@ import androidx.compose.ui.unit.dp object Dimensions { val itemsVerticalPadding = 8.dp + val verticalBarWidth = 64.dp + object thumbnails { val album = 128.dp val artist = 192.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 index 835d2e4..121d7d9 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt @@ -11,6 +11,7 @@ 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.text.BasicText @@ -158,6 +159,7 @@ fun SongItem( .fillMaxWidth() .padding(vertical = Dimensions.itemsVerticalPadding) .padding(start = 16.dp, end = if (trailingContent == null) 16.dp else 8.dp) + .height(Dimensions.thumbnails.song) ) { startContent()