Start working on QuickPicks screen

This commit is contained in:
vfsfitvnm 2022-09-28 21:46:56 +02:00
parent 7a3c0ca110
commit 33778b33dd
37 changed files with 1354 additions and 272 deletions

View file

@ -0,0 +1,670 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "b9a9bb1674c7c50be2fab48de5afed43",
"entities": [
{
"tableName": "Song",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistsText",
"columnName": "artistsText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "durationText",
"columnName": "durationText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lyrics",
"columnName": "lyrics",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "synchronizedLyrics",
"columnName": "synchronizedLyrics",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "totalPlayTimeMs",
"columnName": "totalPlayTimeMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SongPlaylistMap",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId",
"playlistId"
]
},
"indices": [
{
"name": "index_SongPlaylistMap_songId",
"unique": false,
"columnNames": [
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)"
},
{
"name": "index_SongPlaylistMap_playlistId",
"unique": false,
"columnNames": [
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Playlist",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"playlistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Playlist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "browseId",
"columnName": "browseId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Artist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "info",
"columnName": "info",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shuffleVideoId",
"columnName": "shuffleVideoId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shufflePlaylistId",
"columnName": "shufflePlaylistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "radioVideoId",
"columnName": "radioVideoId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "radioPlaylistId",
"columnName": "radioPlaylistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarkedAt",
"columnName": "bookmarkedAt",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SongArtistMap",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId",
"artistId"
]
},
"indices": [
{
"name": "index_SongArtistMap_songId",
"unique": false,
"columnNames": [
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)"
},
{
"name": "index_SongArtistMap_artistId",
"unique": false,
"columnNames": [
"artistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Artist",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"artistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Album",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "authorsText",
"columnName": "authorsText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shareUrl",
"columnName": "shareUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarkedAt",
"columnName": "bookmarkedAt",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SongAlbumMap",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId",
"albumId"
]
},
"indices": [
{
"name": "index_SongAlbumMap_songId",
"unique": false,
"columnNames": [
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)"
},
{
"name": "index_SongAlbumMap_albumId",
"unique": false,
"columnNames": [
"albumId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Album",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"albumId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "SearchQuery",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_SearchQuery_query",
"unique": true,
"columnNames": [
"query"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)"
}
],
"foreignKeys": []
},
{
"tableName": "QueuedMediaItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaItem",
"columnName": "mediaItem",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Format",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itag",
"columnName": "itag",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "bitrate",
"columnName": "bitrate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "contentLength",
"columnName": "contentLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "loudnessDb",
"columnName": "loudnessDb",
"affinity": "REAL",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId"
]
},
"indices": [],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Event",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playTime",
"columnName": "playTime",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Event_songId",
"unique": false,
"columnNames": [
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [
{
"viewName": "SortedSongPlaylistMap",
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position"
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b9a9bb1674c7c50be2fab48de5afed43')"
]
}
}

View file

@ -37,6 +37,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
@ -288,6 +289,19 @@ interface Database {
@Query("SELECT EXISTS(SELECT 1 FROM Playlist WHERE browseId = :browseId)")
fun isImportedPlaylist(browseId: String): Flow<Boolean>
@Transaction
@Query("SELECT Song.* FROM Event JOIN Song ON Song.id = songId GROUP BY songId ORDER BY SUM(playTime / ((:now - timestamp) / 86400000.0)) LIMIT 1")
@RewriteQueriesToDropUnusedColumns
fun trending(now: Long = System.currentTimeMillis()): Flow<DetailedSong?>
// @Transaction
// @Query("SELECT songId FROM Event GROUP BY songId ORDER BY SUM(playTime / ((:now - timestamp) / 86400000.0)) LIMIT 1")
// @RewriteQueriesToDropUnusedColumns
// fun trending(now: Long = System.currentTimeMillis()): Flow<DetailedSong?>
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(event: Event)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(format: Format)
@ -427,11 +441,12 @@ interface Database {
SearchQuery::class,
QueuedMediaItem::class,
Format::class,
Event::class,
],
views = [
SortedSongPlaylistMap::class
],
version = 18,
version = 19,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
@ -448,6 +463,7 @@ interface Database {
AutoMigration(from = 15, to = 16),
AutoMigration(from = 16, to = 17),
AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19),
],
)
@TypeConverters(Converters::class)

View file

@ -0,0 +1,25 @@
package it.vfsfitvnm.vimusic.models
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Immutable
@Entity(
foreignKeys = [
ForeignKey(
entity = Song::class,
parentColumns = ["id"],
childColumns = ["songId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class Event(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(index = true) val songId: String,
val timestamp: Long,
val playTime: Long
)

View file

@ -26,6 +26,6 @@ object DetailedSongSaver : Saver<DetailedSong, List<Any?>> {
thumbnailUrl = value[4] as String?,
totalPlayTimeMs = value[5] as Long,
albumId = value[6] as String?,
artists = InfoListSaver.restore(value[7] as List<List<String>>)
artists = (value[7] as List<List<String>>?)?.let(InfoListSaver::restore)
)
}

View file

@ -8,9 +8,6 @@ object InfoSaver : Saver<Info, List<String>> {
override fun SaverScope.save(value: Info): List<String> = listOf(value.id, value.name)
override fun restore(value: List<String>): Info? {
return if (value.size == 2) Info(
id = value[0],
name = value[1],
) else null
return if (value.size == 2) Info(id = value[0], name = value[1]) else null
}
}

View file

@ -0,0 +1,13 @@
package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
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)
}

View file

@ -3,8 +3,6 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
interface ResultSaver<Original, Saveable> : Saver<Result<Original>?, Pair<Saveable?, Throwable?>>
fun <Original, Saveable : Any> resultSaver(saver: Saver<Original, Saveable>) =
object : Saver<Result<Original>?, Pair<Saveable?, Throwable?>> {
override fun restore(value: Pair<Saveable?, Throwable?>) =

View file

@ -6,15 +6,15 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeAlbumSaver : Saver<YouTube.Item.Album, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Album): List<Any?> = listOf(
with(YouTubeBrowseInfoSaver) { save(value.info) },
with(YouTubeBrowseInfoListSaver) { value.authors?.let { save(it) } },
value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.authors?.let { with(YouTubeBrowseInfoListSaver) { save(it) } },
value.year,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } }
value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Item.Album(
info = YouTubeBrowseInfoSaver.restore(value[0] as List<Any?>),
info = (value[0] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
authors = (value[1] as List<List<Any?>>?)?.let(YouTubeBrowseInfoListSaver::restore),
year = value[2] as String?,
thumbnail = (value[3] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)

View file

@ -6,13 +6,13 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeArtistSaver : Saver<YouTube.Item.Artist, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Artist): List<Any?> = listOf(
with(YouTubeBrowseInfoSaver) { save(value.info) },
value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.subscribersCountText,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } }
)
override fun restore(value: List<Any?>) = YouTube.Item.Artist(
info = YouTubeBrowseInfoSaver.restore(value[0] as List<Any?>),
info = (value[0] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
subscribersCountText = value[1] as String?,
thumbnail = (value[2] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)
)

View file

@ -8,11 +8,11 @@ import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
object YouTubeBrowseInfoSaver : Saver<YouTube.Info<NavigationEndpoint.Endpoint.Browse>, List<Any?>> {
override fun SaverScope.save(value: YouTube.Info<NavigationEndpoint.Endpoint.Browse>) = listOf(
value.name,
with(YouTubeBrowseEndpointSaver) { value.endpoint?.let { save(it) } }
value.endpoint?.let { with(YouTubeBrowseEndpointSaver) { save(it) } }
)
override fun restore(value: List<Any?>) = YouTube.Info(
name = value[0] as String,
name = value[0] as String?,
endpoint = (value[1] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore)
)
}

View file

@ -6,14 +6,14 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubePlaylistSaver : Saver<YouTube.Item.Playlist, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Playlist): List<Any?> = listOf(
with(YouTubeBrowseInfoSaver) { save(value.info) },
with(YouTubeBrowseInfoSaver) { value.channel?.let { save(it) } },
value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.channel?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.songCount,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } }
value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
)
override fun restore(value: List<Any?>) = YouTube.Item.Playlist(
info = YouTubeBrowseInfoSaver.restore(value[0] as List<Any?>),
info = (value[0] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
channel = (value[1] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
songCount = value[2] as Int?,
thumbnail = (value[3] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)

View file

@ -0,0 +1,22 @@
package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeRelatedSaver : Saver<YouTube.Related, List<Any?>> {
override fun SaverScope.save(value: YouTube.Related): List<Any?> = listOf(
value.songs?.let { with(YouTubeSongListSaver) { save(it) } },
value.playlists?.let { with(YouTubePlaylistListSaver) { save(it) } },
value.albums?.let { with(YouTubeAlbumListSaver) { save(it) } },
value.artists?.let { with(YouTubeArtistListSaver) { save(it) } },
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Related(
songs = (value[0] as List<List<Any?>>?)?.let(YouTubeSongListSaver::restore),
playlists = (value[1] as List<List<Any?>>?)?.let(YouTubePlaylistListSaver::restore),
albums = (value[2] as List<List<Any?>>?)?.let(YouTubeAlbumListSaver::restore),
artists = (value[3] as List<List<Any?>>?)?.let(YouTubeArtistListSaver::restore),
)
}

View file

@ -6,17 +6,17 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeSongSaver : Saver<YouTube.Item.Song, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Song): List<Any?> = listOf(
with(YouTubeWatchInfoSaver) { save(value.info) },
with(YouTubeBrowseInfoListSaver) { value.authors?.let { save(it) } },
with(YouTubeBrowseInfoSaver) { value.album?.let { save(it) } },
value.info?.let { with(YouTubeWatchInfoSaver) { save(it) } },
value.authors?.let { with(YouTubeBrowseInfoListSaver) { save(it) } },
value.album?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.durationText,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } }
value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Item.Song(
info = YouTubeWatchInfoSaver.restore(value[0] as List<Any?>),
authors = YouTubeBrowseInfoListSaver.restore(value[1] as List<List<Any?>>),
info = (value[0] as List<Any?>?)?.let(YouTubeWatchInfoSaver::restore),
authors = (value[1] as List<List<Any?>>?)?.let(YouTubeBrowseInfoListSaver::restore),
album = (value[2] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
durationText = value[3] as String?,
thumbnail = (value[4] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)

View file

@ -6,17 +6,17 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeVideoSaver : Saver<YouTube.Item.Video, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Video): List<Any?> = listOf(
with(YouTubeWatchInfoSaver) { save(value.info) },
with(YouTubeBrowseInfoListSaver) { value.authors?.let { save(it) } },
value.info?.let { with(YouTubeWatchInfoSaver) { save(it) } },
value.authors?.let { with(YouTubeBrowseInfoListSaver) { save(it) } },
value.viewsText,
value.durationText,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } }
value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Item.Video(
info = YouTubeWatchInfoSaver.restore(value[0] as List<Any?>),
authors = YouTubeBrowseInfoListSaver.restore(value[1] as List<List<Any?>>),
info = (value[0] as List<Any?>?)?.let(YouTubeWatchInfoSaver::restore),
authors = (value[1] as List<List<Any?>>?)?.let(YouTubeBrowseInfoListSaver::restore),
viewsText = value[2] as String?,
durationText = value[3] as String?,
thumbnail = (value[4] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)

View file

@ -8,11 +8,11 @@ import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
object YouTubeWatchInfoSaver : Saver<YouTube.Info<NavigationEndpoint.Endpoint.Watch>, List<Any?>> {
override fun SaverScope.save(value: YouTube.Info<NavigationEndpoint.Endpoint.Watch>) = listOf(
value.name,
with(YouTubeWatchEndpointSaver) { value.endpoint?.let { save(it) } }
value.endpoint?.let { with(YouTubeWatchEndpointSaver) { save(it) } },
)
override fun restore(value: List<Any?>) = YouTube.Info(
name = value[0] as String,
name = value[0] as String?,
endpoint = (value[1] as List<Any?>?)?.let(YouTubeWatchEndpointSaver::restore)
)
}

View file

@ -65,6 +65,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
@ -285,11 +286,23 @@ 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 {
Database.insert(
Event(
songId = mediaItem.mediaId,
timestamp = System.currentTimeMillis(),
playTime = totalPlayTimeMs
)
)
}
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {

View file

@ -1,5 +1,6 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import android.content.Intent
import android.text.format.DateUtils
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
@ -57,7 +58,6 @@ import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.enqueue
import it.vfsfitvnm.vimusic.utils.forcePlay
import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.shareAsYouTubeSong
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
@ -241,7 +241,13 @@ fun BaseMediaItemMenu(
onGoToAlbum = albumRoute::global,
onGoToArtist = artistRoute::global,
onShare = {
context.shareAsYouTubeSong(mediaItem)
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaItem.mediaId}")
}
context.startActivity(Intent.createChooser(sendIntent, null))
},
modifier = modifier
)

View file

@ -95,7 +95,7 @@ fun AlbumOverview(
title = youtubeAlbum.title,
thumbnailUrl = youtubeAlbum.thumbnail?.url,
year = youtubeAlbum.year,
authorsText = youtubeAlbum.authors?.joinToString("") { it.name },
authorsText = youtubeAlbum.authors?.joinToString("") { it.name ?: "" },
shareUrl = youtubeAlbum.url,
timestamp = System.currentTimeMillis()
),

View file

@ -271,7 +271,7 @@ fun ArtistScreen2(browseId: String) {
) { index, song ->
SongItem(
song = song,
thumbnailSize = songThumbnailSizePx,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(
@ -351,7 +351,7 @@ private suspend fun fetchArtist(browseId: String): Result<Artist>? {
?.map { youtubeArtist ->
Artist(
id = browseId,
name = youtubeArtist.name,
name = youtubeArtist.name ?: "",
thumbnailUrl = youtubeArtist.thumbnail?.url,
info = youtubeArtist.description,
shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId,

View file

@ -103,7 +103,7 @@ fun BuiltInPlaylistSongList(builtInPlaylist: BuiltInPlaylist) {
) { index, song ->
SongItem(
song = song,
thumbnailSize = thumbnailSize,
thumbnailSizePx = thumbnailSize,
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(

View file

@ -58,8 +58,8 @@ import kotlinx.coroutines.flow.flowOn
@ExperimentalFoundationApi
@Composable
fun HomePlaylistList(
onBuiltInPlaylistClicked: (BuiltInPlaylist) -> Unit,
onPlaylistClicked: (Playlist) -> Unit,
onBuiltInPlaylist: (BuiltInPlaylist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
) {
val (colorPalette) = LocalAppearance.current
@ -186,7 +186,7 @@ fun HomePlaylistList(
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onBuiltInPlaylistClicked(BuiltInPlaylist.Favorites) }
onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) }
)
)
}
@ -200,7 +200,7 @@ fun HomePlaylistList(
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onBuiltInPlaylistClicked(BuiltInPlaylist.Offline) }
onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) }
)
.animateItemPlacement()
)
@ -216,7 +216,7 @@ fun HomePlaylistList(
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onPlaylistClicked(playlistPreview.playlist) }
onClick = { onPlaylistClick(playlistPreview.playlist) }
)
.animateItemPlacement()
)

View file

@ -86,30 +86,27 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
tabIndex = tabIndex,
onTabChanged = onTabChanged,
tabColumnContent = { Item ->
Item(0, "Songs", R.drawable.musical_notes)
Item(1, "Playlists", R.drawable.playlist)
Item(2, "Artists", R.drawable.person)
Item(3, "Albums", R.drawable.disc)
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)
},
primaryIconButtonId = R.drawable.search,
onPrimaryIconButtonClick = { searchRoute("") }
) { currentTabIndex ->
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
when (currentTabIndex) {
1 -> HomePlaylistList(
onBuiltInPlaylistClicked = { builtInPlaylistRoute(it) },
onPlaylistClicked = { localPlaylistRoute(it.id) }
0 -> QuickPicks(
onAlbumClick = { albumRoute(it) },
)
2 -> HomeArtistList(
onArtistClick = { artistRoute(it.id) }
1 -> HomeSongList()
2 -> HomePlaylistList(
onBuiltInPlaylist = { builtInPlaylistRoute(it) },
onPlaylistClick = { localPlaylistRoute(it.id) }
)
3 -> HomeAlbumList(
onAlbumClick = { albumRoute(it.id) }
)
else -> HomeSongList()
3 -> HomeArtistList(onArtistClick = { artistRoute(it.id) })
4 -> HomeAlbumList(onAlbumClick = { albumRoute(it.id) })
}
}
}

View file

@ -162,7 +162,7 @@ fun HomeSongList() {
) { index, song ->
SongItem(
song = song,
thumbnailSize = thumbnailSize,
thumbnailSizePx = thumbnailSize,
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(items.map(DetailedSong::asMediaItem), index)

View file

@ -0,0 +1,214 @@
package it.vfsfitvnm.vimusic.ui.screens.home
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateContentSize
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.fillMaxSize
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.lazy.LazyColumn
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.items
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.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.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.savers.DetailedSongSaver
import it.vfsfitvnm.vimusic.savers.YouTubeRelatedSaver
import it.vfsfitvnm.vimusic.savers.nullableSaver
import it.vfsfitvnm.vimusic.savers.resultSaver
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.screens.albumRoute
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.AlbumItem
import it.vfsfitvnm.vimusic.ui.views.SmallSongItem
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.asMediaItem
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.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
@ExperimentalAnimationApi
@Composable
fun QuickPicks(
onAlbumClick: (String) -> Unit
) {
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val trending by produceSaveableState(
initialValue = null,
stateSaver = nullableSaver(DetailedSongSaver),
) {
Database.trending()
.flowOn(Dispatchers.IO)
.filterNotNull()
.distinctUntilChanged()
.collect { value = it }
}
val relatedResult by produceSaveableOneShotState(
initialValue = null,
stateSaver = resultSaver(nullableSaver(YouTubeRelatedSaver)),
trending?.id
) {
println("trendingVideoId: ${trending?.id}")
trending?.id?.let { trendingVideoId ->
value = YouTube.related(trendingVideoId)?.map { related ->
related?.copy(
albums = related.albums?.map { album ->
album.copy(
authors = trending?.artists?.map { info ->
YouTube.Info(
name = info.name,
endpoint = NavigationEndpoint.Endpoint.Browse(
browseId = info.id,
params = null,
browseEndpointContextSupportedConfigs = null
)
)
}
)
}
)
}
}
}
val songThumbnailSizePx = Dimensions.thumbnails.song.px
val albumThumbnailSizeDp = 108.dp
val albumThumbnailSizePx = albumThumbnailSizeDp.px
// val itemInHorizontalGridWidth = (LocalConfiguration.current.screenWidthDp.dp) * 0.8f
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
Header(title = "Quick picks")
}
trending?.let { song ->
item(key = song.id) {
SongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
)
},
menuContent = {
NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
}
)
}
}
relatedResult?.getOrNull()?.let { related ->
items(
items = related.songs?.take(6) ?: emptyList(),
key = YouTube.Item::key
) { song ->
SmallSongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
)
},
)
}
item(
key = "albums",
contentType = "LazyRow"
) {
LazyRow {
items(
items = related.albums ?: emptyList(),
key = YouTube.Item::key
) { album ->
AlbumItem(
album = album,
thumbnailSizePx = albumThumbnailSizePx,
thumbnailSizeDp = albumThumbnailSizeDp,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onAlbumClick(album.key) }
)
.fillMaxWidth()
)
}
}
}
items(
items = related.songs?.drop(6) ?: emptyList(),
key = YouTube.Item::key
) { song ->
SmallSongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
)
},
)
}
}
}
}

View file

@ -165,11 +165,7 @@ fun LocalPlaylistSongList(
transaction {
runBlocking(Dispatchers.IO) {
withContext(Dispatchers.IO) {
YouTube.playlist(browseId)?.map {
it.next()
}?.map { playlist ->
playlist.copy(songs = playlist.songs?.filter { it.info.endpoint != null })
}
YouTube.playlist(browseId)?.map { it.next() }
}
}?.getOrNull()?.let { remotePlaylist ->
Database.clearPlaylist(playlistId)
@ -222,7 +218,7 @@ fun LocalPlaylistSongList(
) { index, song ->
SongItem(
song = song,
thumbnailSize = thumbnailSize,
thumbnailSizePx = thumbnailSize,
onClick = {
playlistWithSongs?.songs?.map(DetailedSong::asMediaItem)
?.let { mediaItems ->

View file

@ -135,7 +135,7 @@ fun Lyrics(
)?.map { it?.value }
} else {
YouTube.next(mediaId, null)
?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() }
?.map { nextResult -> nextResult.lyrics()?.getOrNull() }
}?.map { newLyrics ->
onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "")
state = state.copy(isLoading = false)

View file

@ -149,7 +149,7 @@ fun PlayerBottomSheet(
SongItem(
mediaItem = window.mediaItem,
thumbnailSize = thumbnailSize,
thumbnailSizePx = thumbnailSize,
onClick = {
if (isPlayingThisMediaItem) {
if (shouldBePlaying) {

View file

@ -81,11 +81,7 @@ fun PlaylistSongList(
stateSaver = resultSaver(YouTubePlaylistOrAlbumSaver),
) {
value = withContext(Dispatchers.IO) {
YouTube.playlist(browseId)?.map {
it.next()
}?.map { playlist ->
playlist.copy(songs = playlist.songs?.filter { it.info.endpoint != null })
}
YouTube.playlist(browseId)?.map { it.next() }
}
}
@ -202,8 +198,8 @@ fun PlaylistSongList(
itemsIndexed(items = playlist.songs ?: emptyList()) { index, song ->
SongItem(
title = song.info.name,
authors = (song.authors ?: playlist.authors)?.joinToString("") { it.name },
title = song.info?.name,
authors = (song.authors ?: playlist.authors)?.joinToString("") { it.name ?: "" },
durationText = song.durationText,
onClick = {
playlist.songs?.map(YouTube.Item.Song::asMediaItem)?.let { mediaItems ->

View file

@ -100,7 +100,7 @@ fun LocalSongSearch(
) { song ->
SongItem(
song = song,
thumbnailSize = thumbnailSize,
thumbnailSizePx = thumbnailSize,
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()

View file

@ -92,7 +92,7 @@ inline fun <T : YouTube.Item> SearchResult(
items(
items = items,
key = { it.key!! },
key = YouTube.Item::key,
itemContent = itemContent
)

View file

@ -102,7 +102,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
onClick = {
binder?.stopRadio()
binder?.player?.forcePlay(song.asMediaItem)
binder?.setupRadio(song.info.endpoint)
binder?.setupRadio(song.info?.endpoint)
}
)
},
@ -130,7 +130,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { albumRoute(album.info.endpoint?.browseId) }
onClick = { albumRoute(album.info?.endpoint?.browseId) }
)
)
@ -159,7 +159,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { artistRoute(artist.info.endpoint?.browseId) }
onClick = { artistRoute(artist.info?.endpoint?.browseId) }
)
)
},
@ -186,7 +186,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
onClick = {
binder?.stopRadio()
binder?.player?.forcePlay(video.asMediaItem)
binder?.setupRadio(video.info.endpoint)
binder?.setupRadio(video.info?.endpoint)
}
)
},
@ -217,7 +217,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { playlistRoute(playlist.info.endpoint?.browseId) }
onClick = { playlistRoute(playlist.info?.endpoint?.browseId) }
)
)
},

View file

@ -40,7 +40,7 @@ import it.vfsfitvnm.vimusic.utils.thumbnail
@NonRestartableComposable
fun SongItem(
mediaItem: MediaItem,
thumbnailSize: Int,
thumbnailSizePx: Int,
onClick: () -> Unit,
menuContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
@ -48,7 +48,7 @@ fun SongItem(
trailingContent: (@Composable () -> Unit)? = null
) {
SongItem(
thumbnailModel = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSize),
thumbnailModel = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
title = mediaItem.mediaMetadata.title!!.toString(),
authors = mediaItem.mediaMetadata.artist.toString(),
durationText = mediaItem.mediaMetadata.extras?.getString("durationText") ?: "?",
@ -65,7 +65,7 @@ fun SongItem(
@NonRestartableComposable
fun SongItem(
song: DetailedSong,
thumbnailSize: Int,
thumbnailSizePx: Int,
onClick: () -> Unit,
menuContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
@ -73,7 +73,7 @@ fun SongItem(
trailingContent: (@Composable () -> Unit)? = null
) {
SongItem(
thumbnailModel = song.thumbnailUrl?.thumbnail(thumbnailSize),
thumbnailModel = song.thumbnailUrl?.thumbnail(thumbnailSizePx),
title = song.title,
authors = song.artistsText ?: "",
durationText = song.durationText,
@ -90,8 +90,8 @@ fun SongItem(
@NonRestartableComposable
fun SongItem(
thumbnailModel: Any?,
title: String,
authors: String,
title: String?,
authors: String?,
durationText: String?,
onClick: () -> Unit,
menuContent: @Composable () -> Unit,
@ -131,7 +131,7 @@ fun SongItem(
@ExperimentalAnimationApi
@Composable
fun SongItem(
title: String,
title: String?,
authors: String?,
durationText: String?,
onClick: () -> Unit,
@ -167,7 +167,7 @@ fun SongItem(
.weight(1f)
) {
BasicText(
text = title,
text = title ?: "",
style = typography.xs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View file

@ -79,8 +79,8 @@ fun SmallSongItem(
) {
SongItem(
thumbnailModel = song.thumbnail?.size(thumbnailSizePx),
title = song.info.name,
authors = song.authors?.joinToString("") { it.name } ?: "",
title = song.info?.name,
authors = song.authors?.joinToString("") { it.name ?: "" },
durationText = song.durationText,
onClick = onClick,
menuContent = {
@ -148,14 +148,14 @@ fun VideoItem(
Column {
BasicText(
text = video.info.name,
text = video.info?.name ?: "",
style = typography.xs.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
BasicText(
text = video.authors?.joinToString("") { it.name } ?: "",
text = video.authors?.joinToString("") { it.name ?: "" } ?: "",
style = typography.xs.semiBold.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -252,7 +252,7 @@ fun PlaylistItem(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
BasicText(
text = playlist.info.name,
text = playlist.info?.name ?: "",
style = typography.xs.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@ -322,14 +322,14 @@ fun AlbumItem(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
BasicText(
text = album.info.name,
text = album.info?.name ?: "",
style = typography.xs.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
BasicText(
text = album.authors?.joinToString("") { it.name } ?: "",
text = album.authors?.joinToString("") { it.name ?: "" } ?: "",
style = typography.xs.semiBold.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@ -406,7 +406,7 @@ fun ArtistItem(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
BasicText(
text = artist.info.name,
text = artist.info?.name ?: "",
style = typography.xs.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis

View file

@ -1,7 +1,5 @@
package it.vfsfitvnm.vimusic.utils
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import androidx.core.os.bundleOf
@ -10,37 +8,23 @@ import androidx.media3.common.MediaMetadata
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.youtubemusic.YouTube
fun Context.shareAsYouTubeSong(mediaItem: MediaItem) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaItem.mediaId}")
}
startActivity(Intent.createChooser(sendIntent, null))
}
val YouTube.Item.Song.asMediaItem: MediaItem
get() = MediaItem.Builder()
.also {
// println("$this")
// println(info.endpoint?.videoId)
}
.setMediaId(info.endpoint!!.videoId!!)
.setUri(info.endpoint!!.videoId)
.setCustomCacheKey(info.endpoint!!.videoId)
.setMediaId(key)
.setUri(key)
.setCustomCacheKey(key)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(info.name)
.setArtist(authors?.joinToString("") { it.name })
.setTitle(info?.name)
.setArtist(authors?.joinToString("") { it.name ?: "" })
.setAlbumTitle(album?.name)
.setArtworkUri(thumbnail?.url?.toUri())
.setExtras(
bundleOf(
"videoId" to info.endpoint!!.videoId,
"videoId" to key,
"albumId" to album?.endpoint?.browseId,
"durationText" to durationText,
"artistNames" to authors?.filter { it.endpoint != null }?.map { it.name },
"artistNames" to authors?.filter { it.endpoint != null }?.mapNotNull { it.name },
"artistIds" to authors?.mapNotNull { it.endpoint?.browseId },
)
)
@ -50,19 +34,19 @@ val YouTube.Item.Song.asMediaItem: MediaItem
val YouTube.Item.Video.asMediaItem: MediaItem
get() = MediaItem.Builder()
.setMediaId(info.endpoint!!.videoId!!)
.setUri(info.endpoint!!.videoId)
.setCustomCacheKey(info.endpoint!!.videoId)
.setMediaId(key)
.setUri(key)
.setCustomCacheKey(key)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(info.name)
.setArtist(authors?.joinToString("") { it.name })
.setTitle(info?.name)
.setArtist(authors?.joinToString("") { it.name ?: "" })
.setArtworkUri(thumbnail?.url?.toUri())
.setExtras(
bundleOf(
"videoId" to info.endpoint!!.videoId,
"videoId" to key,
"durationText" to durationText,
"artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.map { it.name } else null,
"artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.mapNotNull { it.name } else null,
"artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } else null,
)
)

View file

@ -20,13 +20,16 @@ import it.vfsfitvnm.youtubemusic.models.BrowseResponse
import it.vfsfitvnm.youtubemusic.models.ContinuationResponse
import it.vfsfitvnm.youtubemusic.models.GetQueueResponse
import it.vfsfitvnm.youtubemusic.models.GetSearchSuggestionsResponse
import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer
import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
import it.vfsfitvnm.youtubemusic.models.MusicTwoRowItemRenderer
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import it.vfsfitvnm.youtubemusic.models.NextResponse
import it.vfsfitvnm.youtubemusic.models.PlayerResponse
import it.vfsfitvnm.youtubemusic.models.Runs
import it.vfsfitvnm.youtubemusic.models.SearchResponse
import it.vfsfitvnm.youtubemusic.models.SectionListRenderer
import it.vfsfitvnm.youtubemusic.models.ThumbnailRenderer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@ -36,7 +39,7 @@ import kotlinx.serialization.json.Json
object YouTube {
private const val Key = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
val client = HttpClient(OkHttp) {
private val client = HttpClient(OkHttp) {
BrowserUserAgent()
expectSuccess = true
@ -162,37 +165,34 @@ object YouTube {
}
data class Info<T : NavigationEndpoint.Endpoint>(
val name: String,
val name: String?,
val endpoint: T?
) {
companion object {
inline fun <reified T : NavigationEndpoint.Endpoint> from(run: Runs.Run): Info<T> {
return Info(
name = run.text,
endpoint = run.navigationEndpoint?.endpoint as T?
)
}
}
@Suppress("UNCHECKED_CAST")
constructor(run: Runs.Run) : this(
name = run.text,
endpoint = run.navigationEndpoint?.endpoint as T?
)
}
sealed class Item {
abstract val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
abstract val key: String?
abstract val key: String
data class Song(
val info: Info<NavigationEndpoint.Endpoint.Watch>,
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val album: Info<NavigationEndpoint.Endpoint.Browse>?,
val durationText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.videoId
override val key: String
get() = info!!.endpoint!!.videoId!!
companion object : FromMusicShelfRendererContent<Song> {
companion object {
val Filter = Filter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Song {
fun from(content: MusicShelfRenderer.Content): Song? {
val (mainRuns, otherRuns) = content.runs
// Possible configurations:
@ -210,21 +210,22 @@ object YouTube {
?.browseEndpoint
?.type == "MUSIC_PAGE_TYPE_ALBUM"
}
?.let(Info.Companion::from)
?.let(::Info)
return Song(
info = Info.from(mainRuns.first()),
info = mainRuns
.firstOrNull()
?.let(::Info),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2)
?.map(Info.Companion::from)
?: emptyList(),
?.map(::Info),
album = album,
durationText = otherRuns
.lastOrNull()
?.firstOrNull()?.text,
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.videoId != null }
}
fun from(renderer: MusicResponsiveListItemRenderer): Song? {
@ -236,15 +237,15 @@ object YouTube {
?.text
?.runs
?.getOrNull(0)
?.let { Info.from(it) } ?: return null,
?.let(::Info),
authors = renderer
.flexColumns
.getOrNull(1)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.map { Info.from<NavigationEndpoint.Endpoint.Browse>(it) }
?.takeIf { it.isNotEmpty() },
?.map<Runs.Run, Info<NavigationEndpoint.Endpoint.Browse>>(::Info)
?.takeIf(List<Any>::isNotEmpty),
durationText = renderer
.fixedColumns
?.getOrNull(0)
@ -260,53 +261,55 @@ object YouTube {
?.text
?.runs
?.firstOrNull()
?.let { Info.from(it) },
?.let(::Info),
thumbnail = renderer
.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
)
).takeIf { it.info?.endpoint?.videoId != null }
}
}
}
data class Video(
val info: Info<NavigationEndpoint.Endpoint.Watch>,
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val viewsText: String?,
val durationText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.videoId
override val key: String
get() = info!!.endpoint!!.videoId!!
val isOfficialMusicVideo: Boolean
get() = info
.endpoint
?.endpoint
?.watchEndpointMusicSupportedConfigs
?.watchEndpointMusicConfig
?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV"
val isUserGeneratedContent: Boolean
get() = info
.endpoint
?.endpoint
?.watchEndpointMusicSupportedConfigs
?.watchEndpointMusicConfig
?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC"
companion object : FromMusicShelfRendererContent<Video> {
companion object {
val Filter = Filter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Video {
fun from(content: MusicShelfRenderer.Content): Video? {
val (mainRuns, otherRuns) = content.runs
return Video(
info = Info.from(mainRuns.first()),
info = mainRuns
.firstOrNull()
?.let(::Info),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - 2)
?.map(Info.Companion::from),
?.map(::Info),
viewsText = otherRuns
.getOrNull(otherRuns.lastIndex - 1)
?.firstOrNull()
@ -317,31 +320,31 @@ object YouTube {
?.text,
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.videoId != null }
}
}
}
data class Album(
val info: Info<NavigationEndpoint.Endpoint.Browse>,
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val year: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.browseId
override val key: String
get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Album> {
companion object {
val Filter = Filter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Album {
fun from(content: MusicShelfRenderer.Content): Album? {
val (mainRuns, otherRuns) = content.runs
return Album(
info = Info(
name = mainRuns
.first()
.text,
.firstOrNull()
?.text,
endpoint = content
.musicResponsiveListItemRenderer
.navigationEndpoint
@ -349,37 +352,59 @@ object YouTube {
),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - 1)
?.map(Info.Companion::from),
?.map(::Info),
year = otherRuns
.getOrNull(otherRuns.lastIndex)
?.firstOrNull()
?.text,
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Album? {
return Album(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
authors = null,
year = renderer
.subtitle
.runs
.lastOrNull()
?.text,
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
}
}
}
data class Artist(
val info: Info<NavigationEndpoint.Endpoint.Browse>,
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val subscribersCountText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.browseId
override val key: String
get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Artist> {
companion object {
val Filter = Filter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Artist {
fun from(content: MusicShelfRenderer.Content): Artist? {
val (mainRuns, otherRuns) = content.runs
return Artist(
info = Info(
name = mainRuns
.first()
.text,
.firstOrNull()
?.text,
endpoint = content
.musicResponsiveListItemRenderer
.navigationEndpoint
@ -391,22 +416,43 @@ object YouTube {
?.text,
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Artist? {
return Artist(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
subscribersCountText = renderer
.subtitle
.runs
.firstOrNull()
?.text,
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
}
}
}
data class Playlist(
val info: Info<NavigationEndpoint.Endpoint.Browse>,
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val channel: Info<NavigationEndpoint.Endpoint.Browse>?,
val songCount: Int?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.browseId
override val key: String
get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Playlist> {
override fun from(content: MusicShelfRenderer.Content): Playlist {
companion object {
fun from(content: MusicShelfRenderer.Content): Playlist? {
val (mainRuns, otherRuns) = content.runs
return Playlist(
@ -422,7 +468,7 @@ object YouTube {
channel = otherRuns
.firstOrNull()
?.firstOrNull()
?.let { Info.from(it) },
?.let(::Info),
songCount = otherRuns
.lastOrNull()
?.firstOrNull()
@ -432,7 +478,36 @@ object YouTube {
?.toIntOrNull(),
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Playlist? {
return Playlist(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
channel = renderer
.subtitle
.runs
.getOrNull(2)
?.let(::Info),
songCount = renderer
.subtitle
.runs
.getOrNull(4)
?.text
?.split(' ')
?.firstOrNull()
?.toIntOrNull(),
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
}
}
}
@ -445,15 +520,11 @@ object YouTube {
val Filter = Filter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D")
}
interface FromMusicShelfRendererContent<out T : Item> {
fun from(content: MusicShelfRenderer.Content): T
}
@JvmInline
value class Filter(val value: String)
}
class SearchResult(val items: List<Item>, val continuation: String?)
class SearchResult(val items: List<Item>?, val continuation: String?)
suspend fun search(
query: String,
@ -495,7 +566,7 @@ object YouTube {
SearchResult(
items = musicShelfRenderer
?.contents
?.map(
?.mapNotNull(
when (filter) {
Item.Song.Filter.value -> Item.Song.Companion::from
Item.Album.Filter.value -> Item.Album.Companion::from
@ -505,7 +576,7 @@ object YouTube {
Item.FeaturedPlaylist.Filter.value -> Item.Playlist.Companion::from
else -> error("Unknown filter: $filter")
}
) ?: emptyList(),
),
continuation = musicShelfRenderer
?.continuations
?.firstOrNull()
@ -623,7 +694,7 @@ object YouTube {
info = Info(
name = renderer
.title
?.text ?: return@let null,
?.text,
endpoint = renderer
.navigationEndpoint
.watchEndpoint
@ -632,14 +703,13 @@ object YouTube {
.longBylineText
?.splitBySeparator()
?.getOrNull(0)
?.map { Info.from(it) }
?: emptyList(),
?.map(::Info),
album = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(1)
?.getOrNull(0)
?.let { Info.from(it) },
?.let(::Info),
thumbnail = renderer
.thumbnail
.thumbnails
@ -647,7 +717,7 @@ object YouTube {
durationText = renderer
.lengthText
?.text
)
).takeIf { it.info?.endpoint?.videoId != null }
}
}
}.recoverIfCancelled()
@ -663,16 +733,6 @@ object YouTube {
)?.map { it?.firstOrNull() }
}
suspend fun queue(playlistId: String): Result<List<Item.Song>?>? {
return getQueue(
GetQueueBody(
context = Context.DefaultWeb,
videoIds = null,
playlistId = playlistId
)
)
}
suspend fun next(
videoId: String?,
playlistId: String?,
@ -759,7 +819,7 @@ object YouTube {
info = Info(
name = renderer
.title
?.text ?: return@mapNotNull null,
?.text,
endpoint = renderer
.navigationEndpoint
.watchEndpoint
@ -768,14 +828,13 @@ object YouTube {
.longBylineText
?.splitBySeparator()
?.getOrNull(0)
?.map { run -> Info.from(run) }
?: emptyList(),
?.map(::Info),
album = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(1)
?.getOrNull(0)
?.let { run -> Info.from(run) },
?.let(::Info),
thumbnail = renderer
.thumbnail
.thumbnails
@ -783,24 +842,14 @@ object YouTube {
durationText = renderer
.lengthText
?.text
)
).takeIf { it.info?.endpoint?.videoId != null }
},
lyrics = NextResult.Lyrics(
browseId = tabs
.getOrNull(1)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
),
related = NextResult.Related(
browseId = tabs
.getOrNull(2)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
)
lyricsBrowseId = tabs
.getOrNull(1)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId,
)
}.recoverIfCancelled()
}
@ -811,32 +860,23 @@ object YouTube {
val params: String? = null,
val playlistSetVideoId: String? = null,
val items: List<Item.Song>?,
val lyrics: Lyrics?,
val related: Related?,
val lyricsBrowseId: String?
) {
class Lyrics(
val browseId: String?,
) {
suspend fun text(): Result<String?>? {
return if (browseId == null) {
Result.success(null)
} else {
browse(browseId)?.map { body ->
body.contents
.sectionListRenderer
?.contents
?.first()
?.musicDescriptionShelfRenderer
?.description
?.text
}
suspend fun lyrics(): Result<String?>? {
return if (lyricsBrowseId == null) {
Result.success(null)
} else {
browse(lyricsBrowseId)?.map { body ->
body.contents
.sectionListRenderer
?.contents
?.first()
?.musicDescriptionShelfRenderer
?.description
?.text
}
}
}
class Related(
val browseId: String?,
)
}
suspend fun browse(browseId: String): Result<BrowseResponse>? {
@ -875,12 +915,14 @@ object YouTube {
parameter("continuation", continuation)
}.body<ContinuationResponse>().let { continuationResponse ->
copy(
songs = songs?.plus(continuationResponse
.continuationContents
.musicShelfContinuation
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from) ?: emptyList()),
songs = songs?.plus(
continuationResponse
.continuationContents
.musicShelfContinuation
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from) ?: emptyList()
),
continuation = continuationResponse
.continuationContents
.musicShelfContinuation
@ -897,7 +939,7 @@ object YouTube {
suspend fun album(browseId: String): Result<PlaylistOrAlbum>? {
return playlistOrAlbum(browseId)?.map { album ->
album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
playlistOrAlbum("VL$playlistId")?.getOrNull()?.let { playlist ->
album.copy(songs = playlist.songs)
}
@ -950,7 +992,7 @@ object YouTube {
?.subtitle
?.splitBySeparator()
?.getOrNull(1)
?.map { Info.from(it) },
?.map(::Info),
year = body
.header
?.musicDetailHeaderRenderer
@ -972,9 +1014,7 @@ object YouTube {
?.musicShelfRenderer
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from)
// ?.filter { it.info.endpoint != null }
,
?.mapNotNull(Item.Song.Companion::from),
url = body
.microformat
?.microformatDataRenderer
@ -999,7 +1039,7 @@ object YouTube {
}
data class Artist(
val name: String,
val name: String?,
val description: String?,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?,
val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?,
@ -1013,7 +1053,7 @@ object YouTube {
.header
?.musicImmersiveHeaderRenderer
?.title
?.text ?: "Unknown",
?.text,
description = body
.header
?.musicImmersiveHeaderRenderer
@ -1045,4 +1085,100 @@ object YouTube {
)
}
}
data class Related(
val songs: List<Item.Song>? = null,
val playlists: List<Item.Playlist>? = null,
val albums: List<Item.Album>? = null,
val artists: List<Item.Artist>? = null,
)
suspend fun related(videoId: String): Result<Related?>? {
return runCatching {
val body = client.post("/youtubei/v1/next") {
contentType(ContentType.Application.Json)
setBody(
NextBody(
context = Context.DefaultWeb,
videoId = videoId,
playlistId = null,
isAudioOnly = true,
tunerSettingValue = "AUTOMIX_SETTING_NORMAL",
watchEndpointMusicSupportedConfigs = NextBody.WatchEndpointMusicSupportedConfigs(
musicVideoType = "MUSIC_VIDEO_TYPE_ATV"
),
index = 0,
playlistSetVideoId = null,
params = null,
continuation = null
)
)
parameter("key", Key)
parameter("prettyPrint", false)
}.body<NextResponse>()
body
.contents
.singleColumnMusicWatchNextResultsRenderer
.tabbedRenderer
.watchNextTabbedResultsRenderer
.tabs
.getOrNull(2)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
?.let { browseId ->
browse(browseId)?.getOrThrow()?.let { browseResponse ->
browseResponse
.contents
.sectionListRenderer
?.contents
?.mapNotNull(SectionListRenderer.Content::musicCarouselShelfRenderer)
?.map(MusicCarouselShelfRenderer::contents)
}
}?.let { contents ->
Related(
songs = contents.find { items ->
items.firstOrNull()?.musicResponsiveListItemRenderer != null
}?.mapNotNull { content ->
Item.Song.from(content.musicResponsiveListItemRenderer!!)
},
playlists = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_PLAYLIST"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Playlist.Companion::from),
albums = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_ALBUM"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Album.Companion::from),
artists = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_ARTIST"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Artist.Companion::from),
)
}
}.recoverIfCancelled()
}
}

View file

@ -1,9 +1,7 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class MusicCarouselShelfRenderer(
val header: Header,
@ -12,7 +10,8 @@ data class MusicCarouselShelfRenderer(
@Serializable
data class Content(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?,
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
)
@Serializable

View file

@ -93,7 +93,7 @@ data class NavigationEndpoint(
@Serializable
data class Browse(
val params: String?,
val browseId: String,
val browseId: String?,
val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?,
) : Endpoint() {
val type: String?