Merge pull request #380 from vfsfitvnm/redesign

Redesign
This commit is contained in:
vfsfitvnm 2022-10-09 09:43:59 +02:00 committed by GitHub
commit f53057585a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
231 changed files with 14460 additions and 9107 deletions

View file

@ -2,7 +2,7 @@ name: CI
on:
push:
branches: ["master"]
branches: ["redesign"]
pull_request:
branches: ["master"]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -72,6 +72,18 @@
android:host="youtu.be"
android:pathPrefix="/"
android:scheme="https" />
<data
android:host="music.youtube.com"
android:pathPrefix="/channel"
android:scheme="https" />
<data
android:host="www.youtube.com"
android:pathPrefix="/channel"
android:scheme="https" />
<data
android:host="m.youtube.com"
android:pathPrefix="/c"
android:scheme="https" />
</intent-filter>
</activity>

View file

@ -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<String?>
@ -144,9 +151,80 @@ interface Database {
@Query("SELECT * FROM Artist WHERE id = :id")
fun artist(id: String): Flow<Artist?>
@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<List<Artist>>
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name ASC")
fun artistsByNameAsc(): Flow<List<Artist>>
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID DESC")
fun artistsByRowIdDesc(): Flow<List<Artist>>
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID ASC")
fun artistsByRowIdAsc(): Flow<List<Artist>>
fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow<List<Artist>> {
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<Album?>
@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<List<DetailedSong>>
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title ASC")
fun albumsByTitleAsc(): Flow<List<Album>>
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year ASC")
fun albumsByYearAsc(): Flow<List<Album>>
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID ASC")
fun albumsByRowIdAsc(): Flow<List<Album>>
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title DESC")
fun albumsByTitleDesc(): Flow<List<Album>>
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year DESC")
fun albumsByYearDesc(): Flow<List<Album>>
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID DESC")
fun albumsByRowIdDesc(): Flow<List<Album>>
fun albums(sortBy: AlbumSortBy, sortOrder: SortOrder): Flow<List<Album>> {
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<List<DetailedSong>>
@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<List<DetailedSong>>
@Query("SELECT * FROM Format WHERE songId = :songId")
fun format(songId: String): Flow<Format>
@ -220,27 +293,29 @@ interface Database {
@Query("SELECT loudnessDb FROM Format WHERE songId = :songId")
fun loudnessDb(songId: String): Flow<Float?>
@Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query")
fun search(query: String): Flow<List<DetailedSong>>
@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<DetailedSong?>
@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<QueuedMediaItem>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertSongPlaylistMaps(songPlaylistMaps: List<SongPlaylistMap>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(album: Album, songAlbumMap: SongAlbumMap)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(artists: List<Artist>, songArtistMaps: List<SongArtistMap>)
@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

View file

@ -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<PlayerService.Binder?>(null)
private var uri by mutableStateOf<Uri?>(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<PlayerService.Binder?> { null }
val LocalPlayerAwarePaddingValues = staticCompositionLocalOf<PaddingValues> { TODO() }
val LocalPlayerAwareWindowInsets = staticCompositionLocalOf<WindowInsets> { TODO() }

View file

@ -0,0 +1,7 @@
package it.vfsfitvnm.vimusic.enums
enum class AlbumSortBy {
Title,
Year,
DateAdded
}

View file

@ -0,0 +1,6 @@
package it.vfsfitvnm.vimusic.enums
enum class ArtistSortBy {
Name,
DateAdded
}

View file

@ -22,11 +22,4 @@ enum class ThumbnailRoundness {
Heavy -> RoundedCornerShape(8.dp)
}
}
companion object {
val shape: Shape
@Composable
@ReadOnlyComposable
get() = LocalAppearance.current.thumbnailShape
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -2,5 +2,5 @@ package it.vfsfitvnm.vimusic.models
data class Info(
val id: String,
val name: String
val name: String?
)

View file

@ -19,9 +19,4 @@ data class PlaylistWithSongs(
)
)
val songs: List<DetailedSong>
) {
companion object {
val Empty = PlaylistWithSongs(Playlist(-1, ""), emptyList())
val NotFound = PlaylistWithSongs(Playlist(-2, "Not found"), emptyList())
}
}
)

View file

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

View file

@ -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<Album, List<Any?>> {
override fun SaverScope.save(value: Album): List<Any?> = listOf(
value.id,
value.title,
value.thumbnailUrl,
value.year,
value.authorsText,
value.shareUrl,
value.timestamp,
value.bookmarkedAt,
)
override fun restore(value: List<Any?>): 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)

View file

@ -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<Artist, List<Any?>> {
override fun SaverScope.save(value: Artist): List<Any?> = listOf(
value.id,
value.name,
value.thumbnailUrl,
value.info,
value.timestamp,
value.bookmarkedAt,
)
override fun restore(value: List<Any?>): 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)

View file

@ -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<DetailedSong, List<Any?>> {
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<Any?>) = 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<List<String>>?)?.let(InfoListSaver::restore)
)
}
val DetailedSongListSaver = listSaver(DetailedSongSaver)

View file

@ -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<Info, List<String?>> {
override fun SaverScope.save(value: Info) = listOf(value.id, value.name)
override fun restore(value: List<String?>) = Info(id = value[0] as String, name = value[1])
}
val InfoListSaver = listSaver(InfoSaver)

View file

@ -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<Innertube.AlbumItem, List<Any?>> {
override fun SaverScope.save(value: Innertube.AlbumItem): List<Any?> = 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<Any?>) = Innertube.AlbumItem(
info = (value[0] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
authors = (value[1] as List<List<Any?>>?)?.let(InnertubeBrowseInfoListSaver::restore),
year = value[2] as String?,
thumbnail = (value[3] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
)
}
val InnertubeAlbumItemListSaver = listSaver(InnertubeAlbumItemSaver)

View file

@ -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<Innertube.ArtistItem, List<Any?>> {
override fun SaverScope.save(value: Innertube.ArtistItem): List<Any?> = listOf(
value.info?.let { with(InnertubeBrowseInfoSaver) { save(it) } },
value.subscribersCountText,
value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } }
)
override fun restore(value: List<Any?>) = Innertube.ArtistItem(
info = (value[0] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
subscribersCountText = value[1] as String?,
thumbnail = (value[2] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
)
}
val InnertubeArtistItemListSaver = listSaver(InnertubeArtistItemSaver)

View file

@ -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<Innertube.ArtistPage, List<Any?>> {
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<Any?>) = Innertube.ArtistPage(
name = value[0] as String?,
description = value[1] as String?,
thumbnail = (value[2] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore),
shuffleEndpoint = (value[3] as List<Any?>?)?.let(InnertubeWatchEndpointSaver::restore),
radioEndpoint = (value[4] as List<Any?>?)?.let(InnertubeWatchEndpointSaver::restore),
songs = (value[5] as List<List<Any?>>?)?.let(InnertubeSongItemListSaver::restore),
songsEndpoint = (value[6] as List<Any?>?)?.let(InnertubeBrowseEndpointSaver::restore),
albums = (value[7] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
albumsEndpoint = (value[8] as List<Any?>?)?.let(InnertubeBrowseEndpointSaver::restore),
singles = (value[9] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
singlesEndpoint = (value[10] as List<Any?>?)?.let(InnertubeBrowseEndpointSaver::restore),
)
}

View file

@ -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<NavigationEndpoint.Endpoint.Browse, List<Any?>> {
override fun SaverScope.save(value: NavigationEndpoint.Endpoint.Browse) = listOf(
value.browseId,
value.params
)
override fun restore(value: List<Any?>) = NavigationEndpoint.Endpoint.Browse(
browseId = value[0] as String,
params = value[1] as String?,
browseEndpointContextSupportedConfigs = null
)
}

View file

@ -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<Innertube.Info<NavigationEndpoint.Endpoint.Browse>, List<Any?>> {
override fun SaverScope.save(value: Innertube.Info<NavigationEndpoint.Endpoint.Browse>) = listOf(
value.name,
value.endpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } }
)
override fun restore(value: List<Any?>) = Innertube.Info(
name = value[0] as String?,
endpoint = (value[1] as List<Any?>?)?.let(InnertubeBrowseEndpointSaver::restore)
)
}
val InnertubeBrowseInfoListSaver = listSaver(InnertubeBrowseInfoSaver)

View file

@ -0,0 +1,4 @@
package it.vfsfitvnm.vimusic.savers
val InnertubeSongsPageSaver = innertubeItemsPageSaver(InnertubeSongItemListSaver)
val InnertubeAlbumsPageSaver = innertubeItemsPageSaver(InnertubeAlbumItemListSaver)

View file

@ -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<Innertube.PlaylistItem, List<Any?>> {
override fun SaverScope.save(value: Innertube.PlaylistItem): List<Any?> = 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<Any?>) = Innertube.PlaylistItem(
info = (value[0] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
channel = (value[1] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
songCount = value[2] as Int?,
thumbnail = (value[3] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
)
}
val InnertubePlaylistItemListSaver = listSaver(InnertubePlaylistItemSaver)

View file

@ -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<Innertube.PlaylistOrAlbumPage, List<Any?>> {
override fun SaverScope.save(value: Innertube.PlaylistOrAlbumPage): List<Any?> = 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<Any?>) = Innertube.PlaylistOrAlbumPage(
title = value[0] as String?,
authors = (value[1] as List<List<Any?>>?)?.let(InnertubeBrowseInfoListSaver::restore),
year = value[2] as String?,
thumbnail = (value[3] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore),
url = value[4] as String?,
songsPage = (value[5] as List<Any?>?)?.let(InnertubeSongsPageSaver::restore),
otherVersions = (value[6] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
)
}

View file

@ -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<Innertube.RelatedPage, List<Any?>> {
override fun SaverScope.save(value: Innertube.RelatedPage): List<Any?> = 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<Any?>) = Innertube.RelatedPage(
songs = (value[0] as List<List<Any?>>?)?.let(InnertubeSongItemListSaver::restore),
playlists = (value[1] as List<List<Any?>>?)?.let(InnertubePlaylistItemListSaver::restore),
albums = (value[2] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
artists = (value[3] as List<List<Any?>>?)?.let(InnertubeArtistItemListSaver::restore),
)
}

View file

@ -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<Innertube.SongItem, List<Any?>> {
override fun SaverScope.save(value: Innertube.SongItem): List<Any?> = 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<Any?>) = Innertube.SongItem(
info = (value[0] as List<Any?>?)?.let(InnertubeWatchInfoSaver::restore),
authors = (value[1] as List<List<Any?>>?)?.let(InnertubeBrowseInfoListSaver::restore),
album = (value[2] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
durationText = value[3] as String?,
thumbnail = (value[4] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
)
}
val InnertubeSongItemListSaver = listSaver(InnertubeSongItemSaver)

View file

@ -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<Thumbnail, List<Any?>> {
override fun SaverScope.save(value: Thumbnail) = listOf(
value.url,
value.width,
value.height
)
override fun restore(value: List<Any?>) = Thumbnail(
url = value[0] as String,
width = value[1] as Int,
height = value[2] as Int?,
)
}

View file

@ -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<Innertube.VideoItem, List<Any?>> {
override fun SaverScope.save(value: Innertube.VideoItem): List<Any?> = 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<Any?>) = Innertube.VideoItem(
info = (value[0] as List<Any?>?)?.let(InnertubeWatchInfoSaver::restore),
authors = (value[1] as List<List<Any?>>?)?.let(InnertubeBrowseInfoListSaver::restore),
viewsText = value[2] as String?,
durationText = value[3] as String?,
thumbnail = (value[4] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
)
}
val InnertubeVideoItemListSaver = listSaver(InnertubeVideoItemSaver)

View file

@ -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<NavigationEndpoint.Endpoint.Watch, List<Any?>> {
override fun SaverScope.save(value: NavigationEndpoint.Endpoint.Watch) = listOf(
value.params,
value.playlistId,
value.videoId,
value.index,
value.playlistSetVideoId,
)
override fun restore(value: List<Any?>) = 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
)
}

View file

@ -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<Innertube.Info<NavigationEndpoint.Endpoint.Watch>, List<Any?>> {
override fun SaverScope.save(value: Innertube.Info<NavigationEndpoint.Endpoint.Watch>) = listOf(
value.name,
value.endpoint?.let { with(InnertubeWatchEndpointSaver) { save(it) } },
)
override fun restore(value: List<Any?>) = Innertube.Info(
name = value[0] as String?,
endpoint = (value[1] as List<Any?>?)?.let(InnertubeWatchEndpointSaver::restore)
)
}

View file

@ -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<PlaylistPreview, List<Any>> {
override fun SaverScope.save(value: PlaylistPreview) = listOf(
with(PlaylistSaver) { save(value.playlist) },
value.songCount,
)
override fun restore(value: List<Any>) = PlaylistPreview(
playlist = PlaylistSaver.restore(value[0] as List<Any?>),
songCount = value[1] as Int,
)
}
val PlaylistPreviewListSaver = listSaver(PlaylistPreviewSaver)

View file

@ -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<Playlist, List<Any?>> {
override fun SaverScope.save(value: Playlist): List<Any?> = listOf(
value.id,
value.name,
value.browseId,
)
override fun restore(value: List<Any?>): Playlist = Playlist(
id = value[0] as Long,
name = value[1] as String,
browseId = value[2] as String?,
)
}

View file

@ -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<PlaylistWithSongs, List<Any>> {
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<Any>): PlaylistWithSongs = PlaylistWithSongs(
playlist = PlaylistSaver.restore(value[0] as List<Any?>),
songs = DetailedSongListSaver.restore(value[1] as List<List<Any?>>)
)
}

View file

@ -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<Original, Saveable : Any> : Saver<List<Original>, List<Saveable>> {
override fun SaverScope.save(value: List<Original>): List<Saveable>
override fun restore(value: List<Saveable>): List<Original>
}
fun <Original, Saveable : Any> resultSaver(saver: Saver<Original, Saveable>) =
object : Saver<Result<Original>?, Pair<Saveable?, Throwable?>> {
override fun restore(value: Pair<Saveable?, Throwable?>) =
value.first?.let(saver::restore)?.let(Result.Companion::success)
?: value.second?.let(Result.Companion::failure)
override fun SaverScope.save(value: Result<Original>?) =
with(saver) { value?.getOrNull()?.let { save(it) } } to value?.exceptionOrNull()
}
fun <Original, Saveable : Any> listSaver(saver: Saver<Original, Saveable>) =
object : ListSaver<Original, Saveable> {
override fun restore(value: List<Saveable>) =
value.mapNotNull(saver::restore)
override fun SaverScope.save(value: List<Original>) =
with(saver) { value.mapNotNull { save(it) } }
}
fun <Original, Saveable : Any> nullableSaver(saver: Saver<Original, Saveable>) =
object : Saver<Original?, Saveable> {
override fun SaverScope.save(value: Original?): Saveable? =
value?.let { with(saver) { save(it) } }
override fun restore(value: Saveable): Original? =
saver.restore(value)
}
fun <Original : Innertube.Item> innertubeItemsPageSaver(saver: ListSaver<Original, List<Any?>>) =
object : Saver<Innertube.ItemsPage<Original>, List<Any?>> {
override fun SaverScope.save(value: Innertube.ItemsPage<Original>) = listOf(
value.items?.let { with(saver) { save(it) } },
value.continuation
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = Innertube.ItemsPage(
items = (value[0] as List<List<Any?>>?)?.let(saver::restore),
continuation = value[1] as String?
)
}

View file

@ -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<SearchQuery, List<Any?>> {
override fun SaverScope.save(value: SearchQuery): List<Any?> = listOf(
value.id,
value.query,
)
override fun restore(value: List<Any?>) = SearchQuery(
id = value[0] as Long,
query = value[1] as String
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <T> ChipGroup(
items: List<ChipItem<T>>,
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<T>(
val text: String,
val value: T
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Boolean>,
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)
}

View file

@ -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?>(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?>(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?>(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<ScrollingInfo?>,
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)
)
}
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Artist>? {
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)
}
}

View file

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

View file

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

View file

@ -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<Result<List<YouTube.Item.Song>>?>(null)
}
var playlistBrowseId by rememberSaveable {
mutableStateOf<String?>(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)
)
}
}
}

View file

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

View file

@ -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<Result<YouTube.PlaylistOrAlbum>?>(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)
)
}
}
}
}
}

View file

@ -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<String?>("albumRoute")
val appearanceSettingsRoute = Route0("appearanceSettingsRoute")
val artistRoute = Route1<String?>("artistRoute")
val backupAndRestoreRoute = Route0("backupAndRestoreRoute")
val builtInPlaylistRoute = Route1<BuiltInPlaylist>("builtInPlaylistRoute")
val cacheSettingsRoute = Route0("cacheSettingsRoute")
val intentUriRoute = Route1<Uri?>("intentUriRoute")
val localPlaylistRoute = Route1<Long?>("localPlaylistRoute")
val otherSettingsRoute = Route0("otherSettingsRoute")
val playerSettingsRoute = Route0("playerSettingsRoute")
val playlistRoute = Route1<String?>("playlistRoute")
val searchResultRoute = Route1<String>("searchResultRoute")
val searchRoute = Route1<String>("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")
)
}
}

View file

@ -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<YouTube.Item>()
}
var continuationResult by remember(searchFilter) {
mutableStateOf<Result<String?>?>(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)
)
}
}
}
}

View file

@ -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<Result<List<String>?>?>(
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) {}
}
}
}
}
}
}
}

View file

@ -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 <reified T : Enum<T>> EnumValueSelectorSettingsEntry(
title: String,
selectedValue: T,
crossinline onValueSelected: (T) -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
crossinline valueText: (T) -> String = Enum<T>::name
) {
ValueSelectorSettingsEntry(
title = title,
selectedValue = selectedValue,
values = enumValues<T>().toList(),
onValueSelected = onValueSelected,
modifier = modifier,
isEnabled = isEnabled,
valueText = valueText
)
}
@Composable
inline fun <T> ValueSelectorSettingsEntry(
title: String,
selectedValue: T,
values: List<T>,
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)
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("") }
)
}
}
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -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<Any>(),
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)
)
}
}
}
)
}
}

View file

@ -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<Long?>(null)
}
val likedAt by remember(mediaId) {
Database.likedAt(mediaId).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
val likedAt by produceSaveableState<Long?>(
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)
)

Some files were not shown because too many files have changed in this diff Show more