commit
f53057585a
231 changed files with 14460 additions and 9107 deletions
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
|
@ -2,7 +2,7 @@ name: CI
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
branches: ["redesign"]
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
610
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json
Normal file
610
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
670
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json
Normal file
670
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
670
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json
Normal file
670
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
646
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json
Normal file
646
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package it.vfsfitvnm.vimusic.enums
|
||||
|
||||
enum class AlbumSortBy {
|
||||
Title,
|
||||
Year,
|
||||
DateAdded
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package it.vfsfitvnm.vimusic.enums
|
||||
|
||||
enum class ArtistSortBy {
|
||||
Name,
|
||||
DateAdded
|
||||
}
|
|
@ -22,11 +22,4 @@ enum class ThumbnailRoundness {
|
|||
Heavy -> RoundedCornerShape(8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shape: Shape
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalAppearance.current.thumbnailShape
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
25
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt
Normal file
25
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt
Normal 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
|
||||
)
|
|
@ -2,5 +2,5 @@ package it.vfsfitvnm.vimusic.models
|
|||
|
||||
data class Info(
|
||||
val id: String,
|
||||
val name: String
|
||||
val name: String?
|
||||
)
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
13
app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InfoSaver.kt
Normal file
13
app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InfoSaver.kt
Normal 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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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),
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -0,0 +1,4 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
val InnertubeSongsPageSaver = innertubeItemsPageSaver(InnertubeSongItemListSaver)
|
||||
val InnertubeAlbumsPageSaver = innertubeItemsPageSaver(InnertubeAlbumItemListSaver)
|
|
@ -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)
|
|
@ -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),
|
||||
)
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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?,
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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?,
|
||||
)
|
||||
}
|
|
@ -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?>>)
|
||||
)
|
||||
}
|
52
app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/Savers.kt
Normal file
52
app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/Savers.kt
Normal 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?
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
155
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt
Normal file
155
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
145
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt
Normal file
145
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
218
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt
Normal file
218
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
149
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt
Normal file
149
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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("") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue