Cache album information

This commit is contained in:
vfsfitvnm 2022-06-30 15:33:36 +02:00
parent f126972f2d
commit 3429f27840
7 changed files with 553 additions and 558 deletions

View file

@ -29,7 +29,7 @@ interface Database {
fun insert(info: Artist) fun insert(info: Artist)
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(info: Album) fun insert(info: Album): Long
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(playlist: Playlist): Long fun insert(playlist: Playlist): Long
@ -111,6 +111,21 @@ interface Database {
@Update @Update
fun update(album: Album) fun update(album: Album)
fun upsert(album: Album) {
if (insert(album) == -1L) {
update(album)
}
}
@Update
fun update(songAlbumMap: SongAlbumMap)
fun upsert(songAlbumMap: SongAlbumMap) {
if (insert(songAlbumMap) == -1L) {
update(songAlbumMap)
}
}
@Update @Update
fun update(songInPlaylist: SongInPlaylist) fun update(songInPlaylist: SongInPlaylist)
@ -141,10 +156,10 @@ interface Database {
@RewriteQueriesToDropUnusedColumns @RewriteQueriesToDropUnusedColumns
fun artistSongs(artistId: String): Flow<List<DetailedSong>> fun artistSongs(artistId: String): Flow<List<DetailedSong>>
// @Transaction @Transaction
// @Query("SELECT * FROM Song JOIN SongArtistMap ON Song.id = SongArtistMap.songId WHERE SongArtistMap.artistId = :artistId ORDER BY Song.ROWID DESC") @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position")
// @RewriteQueriesToDropUnusedColumns @RewriteQueriesToDropUnusedColumns
// fun albumSongs(albumId: String): Flow<List<DetailedSong>> fun albumSongs(albumId: String): Flow<List<DetailedSong>>
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
fun insertQueue(queuedMediaItems: List<QueuedMediaItem>) fun insertQueue(queuedMediaItems: List<QueuedMediaItem>)
@ -181,7 +196,7 @@ interface Database {
AutoMigration(from = 5, to = 6), AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7), AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8, spec = DatabaseInitializer.From7To8Migration::class), AutoMigration(from = 7, to = 8, spec = DatabaseInitializer.From7To8Migration::class),
AutoMigration(from = 9, to = 10), AutoMigration(from = 9, to = 10)
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -196,7 +211,6 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
if (!::Instance.isInitialized) { if (!::Instance.isInitialized) {
Instance = Room Instance = Room
.databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db")
// .addMigrations(From8To9Migration())
.addMigrations(From8To9Migration(), From10To11Migration()) .addMigrations(From8To9Migration(), From10To11Migration())
.build() .build()
} }

View file

@ -7,8 +7,8 @@ import androidx.room.PrimaryKey
data class Album( data class Album(
@PrimaryKey val id: String, @PrimaryKey val id: String,
val title: String?, val title: String?,
val thumbnailUrl: String?, val thumbnailUrl: String? = null,
val year: String?, val year: String? = null,
val authorsText: String?, val authorsText: String? = null,
val shareUrl: String? val shareUrl: String? = null
) )

View file

@ -10,7 +10,8 @@ data class DetailedSong(
@Relation( @Relation(
entity = SongAlbumMap::class, entity = SongAlbumMap::class,
entityColumn = "songId", entityColumn = "songId",
parentColumn = "id" parentColumn = "id",
projection = ["albumId"]
) )
val albumId: String?, val albumId: String?,
@Relation( @Relation(

View file

@ -11,7 +11,10 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.* 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -27,28 +30,25 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.* import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.Album import it.vfsfitvnm.vimusic.models.Album
import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.models.SongInPlaylist import it.vfsfitvnm.vimusic.models.SongAlbumMap
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.TopAppBar import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.* import it.vfsfitvnm.vimusic.ui.components.themed.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.views.SongItem import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.* import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi @ExperimentalAnimationApi
@ -71,13 +71,25 @@ fun AlbumScreen(
year = youtubeAlbum.year, year = youtubeAlbum.year,
authorsText = youtubeAlbum.authors?.joinToString("") { it.name }, authorsText = youtubeAlbum.authors?.joinToString("") { it.name },
shareUrl = youtubeAlbum.url shareUrl = youtubeAlbum.url
).also(Database::update) ).also(Database::upsert).also {
youtubeAlbum.items?.forEachIndexed { position, albumItem ->
albumItem.toMediaItem(browseId, youtubeAlbum)?.let(Database::insert)?.let { song ->
Database.upsert(
SongAlbumMap(
songId = song.id,
albumId = browseId,
position = position
)
)
}
}
}
} }
}.distinctUntilChanged() }.distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO) }.collectAsState(initial = null, context = Dispatchers.IO)
val songs by remember(browseId) { val songs by remember(browseId) {
Database.artistSongs(browseId) Database.albumSongs(browseId)
}.collectAsState(initial = emptyList(), context = Dispatchers.IO) }.collectAsState(initial = emptyList(), context = Dispatchers.IO)
val albumRoute = rememberAlbumRoute() val albumRoute = rememberAlbumRoute()
@ -111,12 +123,6 @@ fun AlbumScreen(
} }
} }
val (songThumbnailSizeDp, songThumbnailSizePx) = remember {
density.run {
54.dp to 54.dp.roundToPx()
}
}
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = PaddingValues(bottom = 72.dp), contentPadding = PaddingValues(bottom = 72.dp),
@ -155,19 +161,9 @@ fun AlbumScreen(
text = "Enqueue", text = "Enqueue",
onClick = { onClick = {
menuState.hide() menuState.hide()
albumResult binder?.player?.enqueue(
?.getOrNull() songs.map(DetailedSong::asMediaItem)
?.let { album -> )
// album.items
// ?.mapNotNull { song ->
// song.toMediaItem(browseId, album)
// }
// ?.let { mediaItems ->
// binder?.player?.enqueue(
// mediaItems
// )
// }
}
} }
) )
@ -259,16 +255,9 @@ fun AlbumScreen(
modifier = Modifier modifier = Modifier
.clickable { .clickable {
binder?.stopRadio() binder?.stopRadio()
// playlistOrAlbum.items binder?.player?.forcePlayFromBeginning(
// ?.shuffled() songs.shuffled().map(DetailedSong::asMediaItem)
// ?.mapNotNull { song -> )
// song.toMediaItem(browseId, playlistOrAlbum)
// }
// ?.let { mediaItems ->
// binder?.player?.forcePlayFromBeginning(
// mediaItems
// )
// }
} }
.shadow(elevation = 2.dp, shape = CircleShape) .shadow(elevation = 2.dp, shape = CircleShape)
.background( .background(
@ -286,15 +275,9 @@ fun AlbumScreen(
modifier = Modifier modifier = Modifier
.clickable { .clickable {
binder?.stopRadio() binder?.stopRadio()
// playlistOrAlbum.items binder?.player?.forcePlayFromBeginning(
// ?.mapNotNull { song -> songs.map(DetailedSong::asMediaItem)
// song.toMediaItem(browseId, playlistOrAlbum) )
// }
// ?.let { mediaItems ->
// binder?.player?.forcePlayFromBeginning(
// mediaItems
// )
// }
} }
.shadow(elevation = 2.dp, shape = CircleShape) .shadow(elevation = 2.dp, shape = CircleShape)
.background( .background(
@ -308,405 +291,36 @@ fun AlbumScreen(
} }
} }
} ?: albumResult?.exceptionOrNull()?.let { throwable -> } ?: albumResult?.exceptionOrNull()?.let { throwable ->
LoadingOrError( LoadingOrError(errorMessage = throwable.javaClass.canonicalName)
errorMessage = throwable.javaClass.canonicalName, } ?: LoadingOrError()
onRetry = {
query {
runBlocking {
Database.album(browseId).first()?.let(Database::update)
}
}
}
)
} ?: Loading()
}
// itemsIndexed(
// items = playlistOrAlbum.valueOrNull?.items ?: emptyList(),
// contentType = { _, song -> song }
// ) { index, song ->
// SongItem(
// title = song.info.name,
// authors = (song.authors ?: playlistOrAlbum.valueOrNull?.authors)?.joinToString("") { it.name },
// durationText = song.durationText,
// onClick = {
// binder?.stopRadio()
// playlistOrAlbum.valueOrNull?.items?.mapNotNull { song ->
// song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!)
// }?.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(songThumbnailSizeDp)
// )
// }
// },
// menuContent = {
// NonQueuedMediaItemMenu(
// mediaItem = song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!)
// ?: return@SongItem,
// onDismiss = menuState::hide,
// )
// }
// )
// }
}
}
}
}
@ExperimentalAnimationApi
@Composable
fun PlaylistScreen(
browseId: String,
) {
val lazyListState = rememberLazyListState()
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val context = LocalContext.current
val density = LocalDensity.current
val binder = LocalPlayerServiceBinder.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val menuState = LocalMenuState.current
val (thumbnailSizeDp, thumbnailSizePx) = remember {
density.run {
128.dp to 128.dp.roundToPx()
}
}
val (songThumbnailSizeDp, songThumbnailSizePx) = remember {
density.run {
54.dp to 54.dp.roundToPx()
}
}
var playlistOrAlbum by remember {
mutableStateOf<Outcome<YouTube.PlaylistOrAlbum>>(Outcome.Loading)
}
val onLoad = relaunchableEffect(Unit) {
playlistOrAlbum = withContext(Dispatchers.IO) {
YouTube.playlistOrAlbum2(browseId)
}
}
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = 72.dp),
modifier = Modifier
.background(colorPalette.background)
.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 {
MenuCloseButton(onClick = menuState::hide)
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
onClick = {
menuState.hide()
playlistOrAlbum.valueOrNull?.let { album ->
album.items
?.mapNotNull { song ->
song.toMediaItem(browseId, album)
}
?.let { mediaItems ->
binder?.player?.enqueue(
mediaItems
)
}
}
}
)
MenuEntry(
icon = R.drawable.list,
text = "Import as playlist",
onClick = {
menuState.hide()
playlistOrAlbum.valueOrNull?.let { album ->
transaction {
val playlistId =
Database.insert(
Playlist(
name = album.title
?: "Unknown"
)
)
album.items?.forEachIndexed { index, song ->
song
.toMediaItem(browseId, album)
?.let { mediaItem ->
Database.insert(mediaItem)
Database.insert(
SongInPlaylist(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = index
)
)
}
}
}
}
}
)
MenuEntry(
icon = R.drawable.share_social,
text = "Share",
onClick = {
menuState.hide()
(playlistOrAlbum.valueOrNull?.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 = 16.dp, vertical = 8.dp)
.size(24.dp)
)
}
}
item {
OutcomeItem(
outcome = playlistOrAlbum,
onRetry = onLoad,
onLoading = {
Loading()
}
) { playlistOrAlbum ->
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Max)
.padding(vertical = 8.dp, horizontal = 16.dp)
.padding(bottom = 16.dp)
) {
AsyncImage(
model = playlistOrAlbum.thumbnail?.size(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(thumbnailSizeDp)
)
Column(
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxSize()
) {
Column {
BasicText(
text = playlistOrAlbum.title ?: "Unknown",
style = typography.m.semiBold
)
BasicText(
text = buildString {
val authors = playlistOrAlbum.authors?.joinToString("") { it.name }
append(authors)
if (authors?.isNotEmpty() == true && playlistOrAlbum.year != null) {
append("")
}
append(playlistOrAlbum.year)
},
style = typography.xs.secondary.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 16.dp)
) {
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
binder?.stopRadio()
playlistOrAlbum.items
?.shuffled()
?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}
?.let { mediaItems ->
binder?.player?.forcePlayFromBeginning(
mediaItems
)
}
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
color = colorPalette.elevatedBackground,
shape = CircleShape
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
binder?.stopRadio()
playlistOrAlbum.items
?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}
?.let { mediaItems ->
binder?.player?.forcePlayFromBeginning(
mediaItems
)
}
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
color = colorPalette.elevatedBackground,
shape = CircleShape
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
}
}
}
}
} }
itemsIndexed( itemsIndexed(
items = playlistOrAlbum.valueOrNull?.items ?: emptyList(), items = songs,
key = { _, song -> song.song.id },
contentType = { _, song -> song } contentType = { _, song -> song }
) { index, song -> ) { index, song ->
SongItem( SongItem(
title = song.info.name, title = song.song.title,
authors = (song.authors ?: playlistOrAlbum.valueOrNull?.authors)?.joinToString("") { it.name }, authors = song.song.artistsText ?: albumResult?.getOrNull()?.authorsText,
durationText = song.durationText, durationText = song.song.durationText,
onClick = { onClick = {
binder?.stopRadio() binder?.stopRadio()
playlistOrAlbum.valueOrNull?.items?.mapNotNull { song -> binder?.player?.forcePlayAtIndex(songs.map(DetailedSong::asMediaItem), index)
song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!)
}?.let { mediaItems ->
binder?.player?.forcePlayAtIndex(mediaItems, index)
}
}, },
startContent = { startContent = {
if (song.thumbnail == null) { BasicText(
BasicText( text = "${index + 1}",
text = "${index + 1}", style = typography.xs.secondary.bold.center,
style = typography.xs.secondary.bold.center, maxLines = 1,
maxLines = 1, overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.Ellipsis, modifier = Modifier
modifier = Modifier .width(36.dp)
.width(36.dp) )
)
} else {
AsyncImage(
model = song.thumbnail!!.size(songThumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(songThumbnailSizeDp)
)
}
}, },
menuContent = { menuContent = {
NonQueuedMediaItemMenu( NonQueuedMediaItemMenu(
mediaItem = song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!) mediaItem = song.asMediaItem,
?: return@SongItem,
onDismiss = menuState::hide, onDismiss = menuState::hide,
) )
} }
@ -717,79 +331,6 @@ fun PlaylistScreen(
} }
} }
@Composable
private fun Loading() {
val colorPalette = LocalColorPalette.current
Column(
modifier = Modifier
.shimmer()
) {
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.darkGray, shape = ThumbnailRoundness.shape)
.size(128.dp)
)
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(54.dp)
.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 = colorPalette.darkGray, shape = CircleShape)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextPlaceholder()
TextPlaceholder(
modifier = Modifier
.alpha(0.7f)
)
}
}
}
}
}
@Composable @Composable
private fun LoadingOrError( private fun LoadingOrError(
@ -832,41 +373,6 @@ private fun LoadingOrError(
} }
} }
} }
repeat(3) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.alpha(0.6f - it * 0.1f)
.height(54.dp)
.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 = colorPalette.darkGray, shape = CircleShape)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextPlaceholder()
TextPlaceholder(
modifier = Modifier
.alpha(0.7f)
)
}
}
}
} }
errorMessage?.let { errorMessage?.let {

View file

@ -0,0 +1,474 @@
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.*
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.*
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.draw.shadow
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
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.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.transaction
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun PlaylistScreen(
browseId: String,
) {
val lazyListState = rememberLazyListState()
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val context = LocalContext.current
val density = LocalDensity.current
val binder = LocalPlayerServiceBinder.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val menuState = LocalMenuState.current
val (thumbnailSizeDp, thumbnailSizePx) = remember {
density.run {
128.dp to 128.dp.roundToPx()
}
}
val (songThumbnailSizeDp, songThumbnailSizePx) = remember {
density.run {
54.dp to 54.dp.roundToPx()
}
}
var playlistOrAlbum by remember {
mutableStateOf<Outcome<YouTube.PlaylistOrAlbum>>(Outcome.Loading)
}
val onLoad = relaunchableEffect(Unit) {
playlistOrAlbum = withContext(Dispatchers.IO) {
YouTube.playlistOrAlbum2(browseId)
}
}
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = 72.dp),
modifier = Modifier
.background(colorPalette.background)
.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 {
MenuCloseButton(onClick = menuState::hide)
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
onClick = {
menuState.hide()
playlistOrAlbum.valueOrNull?.let { album ->
album.items
?.mapNotNull { song ->
song.toMediaItem(browseId, album)
}
?.let { mediaItems ->
binder?.player?.enqueue(
mediaItems
)
}
}
}
)
MenuEntry(
icon = R.drawable.list,
text = "Import as playlist",
onClick = {
menuState.hide()
playlistOrAlbum.valueOrNull?.let { album ->
transaction {
val playlistId =
Database.insert(
Playlist(
name = album.title
?: "Unknown"
)
)
album.items?.forEachIndexed { index, song ->
song
.toMediaItem(browseId, album)
?.let { mediaItem ->
Database.insert(mediaItem)
Database.insert(
SongInPlaylist(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = index
)
)
}
}
}
}
}
)
MenuEntry(
icon = R.drawable.share_social,
text = "Share",
onClick = {
menuState.hide()
(playlistOrAlbum.valueOrNull?.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 = 16.dp, vertical = 8.dp)
.size(24.dp)
)
}
}
item {
OutcomeItem(
outcome = playlistOrAlbum,
onRetry = onLoad,
onLoading = {
Loading()
}
) { playlistOrAlbum ->
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Max)
.padding(vertical = 8.dp, horizontal = 16.dp)
.padding(bottom = 16.dp)
) {
AsyncImage(
model = playlistOrAlbum.thumbnail?.size(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(thumbnailSizeDp)
)
Column(
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxSize()
) {
Column {
BasicText(
text = playlistOrAlbum.title ?: "Unknown",
style = typography.m.semiBold
)
BasicText(
text = buildString {
val authors =
playlistOrAlbum.authors?.joinToString("") { it.name }
append(authors)
if (authors?.isNotEmpty() == true && playlistOrAlbum.year != null) {
append("")
}
append(playlistOrAlbum.year)
},
style = typography.xs.secondary.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 16.dp)
) {
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
binder?.stopRadio()
playlistOrAlbum.items
?.shuffled()
?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}
?.let { mediaItems ->
binder?.player?.forcePlayFromBeginning(
mediaItems
)
}
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
color = colorPalette.elevatedBackground,
shape = CircleShape
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
binder?.stopRadio()
playlistOrAlbum.items
?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}
?.let { mediaItems ->
binder?.player?.forcePlayFromBeginning(
mediaItems
)
}
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
color = colorPalette.elevatedBackground,
shape = CircleShape
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
}
}
}
}
}
itemsIndexed(
items = playlistOrAlbum.valueOrNull?.items ?: emptyList(),
contentType = { _, song -> song }
) { index, song ->
SongItem(
title = song.info.name,
authors = (song.authors
?: playlistOrAlbum.valueOrNull?.authors)?.joinToString("") { it.name },
durationText = song.durationText,
onClick = {
binder?.stopRadio()
playlistOrAlbum.valueOrNull?.items?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!)
}?.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(songThumbnailSizeDp)
)
}
},
menuContent = {
NonQueuedMediaItemMenu(
mediaItem = song.toMediaItem(
browseId,
playlistOrAlbum.valueOrNull!!
)
?: return@SongItem,
onDismiss = menuState::hide,
)
}
)
}
}
}
}
}
@Composable
private fun Loading() {
val colorPalette = LocalColorPalette.current
Column(
modifier = Modifier
.shimmer()
) {
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.darkGray, shape = ThumbnailRoundness.shape)
.size(128.dp)
)
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(54.dp)
.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 = colorPalette.darkGray, shape = CircleShape)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextPlaceholder()
TextPlaceholder(
modifier = Modifier
.alpha(0.7f)
)
}
}
}
}
}

View file

@ -46,9 +46,9 @@ fun Database.insert(mediaItem: MediaItem): Song {
authorsText = null, authorsText = null,
thumbnailUrl = null, thumbnailUrl = null,
shareUrl = null, shareUrl = null,
).also(::insert) ).also(::upsert)
insert( upsert(
SongAlbumMap( SongAlbumMap(
songId = song.id, songId = song.id,
albumId = albumId, albumId = albumId,

View file

@ -690,7 +690,7 @@ object YouTube {
} }
suspend fun browse2(browseId: String): Result<BrowseResponse> { suspend fun browse2(browseId: String): Result<BrowseResponse> {
return runCatching { return runCatching<YouTube, BrowseResponse> {
client.post("/youtubei/v1/browse") { client.post("/youtubei/v1/browse") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody( setBody(