Cache album information
This commit is contained in:
parent
f126972f2d
commit
3429f27840
|
@ -29,7 +29,7 @@ interface Database {
|
|||
fun insert(info: Artist)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(info: Album)
|
||||
fun insert(info: Album): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
fun insert(playlist: Playlist): Long
|
||||
|
@ -111,6 +111,21 @@ interface Database {
|
|||
@Update
|
||||
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
|
||||
fun update(songInPlaylist: SongInPlaylist)
|
||||
|
||||
|
@ -141,10 +156,10 @@ interface Database {
|
|||
@RewriteQueriesToDropUnusedColumns
|
||||
fun artistSongs(artistId: String): Flow<List<DetailedSong>>
|
||||
|
||||
// @Transaction
|
||||
// @Query("SELECT * FROM Song JOIN SongArtistMap ON Song.id = SongArtistMap.songId WHERE SongArtistMap.artistId = :artistId ORDER BY Song.ROWID DESC")
|
||||
// @RewriteQueriesToDropUnusedColumns
|
||||
// fun albumSongs(albumId: String): Flow<List<DetailedSong>>
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun albumSongs(albumId: String): Flow<List<DetailedSong>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
fun insertQueue(queuedMediaItems: List<QueuedMediaItem>)
|
||||
|
@ -181,7 +196,7 @@ interface Database {
|
|||
AutoMigration(from = 5, to = 6),
|
||||
AutoMigration(from = 6, to = 7),
|
||||
AutoMigration(from = 7, to = 8, spec = DatabaseInitializer.From7To8Migration::class),
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 9, to = 10)
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
@ -196,7 +211,6 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
|
|||
if (!::Instance.isInitialized) {
|
||||
Instance = Room
|
||||
.databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db")
|
||||
// .addMigrations(From8To9Migration())
|
||||
.addMigrations(From8To9Migration(), From10To11Migration())
|
||||
.build()
|
||||
}
|
||||
|
|
|
@ -7,8 +7,8 @@ import androidx.room.PrimaryKey
|
|||
data class Album(
|
||||
@PrimaryKey val id: String,
|
||||
val title: String?,
|
||||
val thumbnailUrl: String?,
|
||||
val year: String?,
|
||||
val authorsText: String?,
|
||||
val shareUrl: String?
|
||||
val thumbnailUrl: String? = null,
|
||||
val year: String? = null,
|
||||
val authorsText: String? = null,
|
||||
val shareUrl: String? = null
|
||||
)
|
||||
|
|
|
@ -10,7 +10,8 @@ data class DetailedSong(
|
|||
@Relation(
|
||||
entity = SongAlbumMap::class,
|
||||
entityColumn = "songId",
|
||||
parentColumn = "id"
|
||||
parentColumn = "id",
|
||||
projection = ["albumId"]
|
||||
)
|
||||
val albumId: String?,
|
||||
@Relation(
|
||||
|
|
|
@ -11,7 +11,10 @@ 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.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
|
@ -27,28 +30,25 @@ import androidx.compose.ui.unit.dp
|
|||
import coil.compose.AsyncImage
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
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.enums.ThumbnailRoundness
|
||||
import it.vfsfitvnm.vimusic.models.Album
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.SongInPlaylist
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.models.SongAlbumMap
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
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.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -71,13 +71,25 @@ fun AlbumScreen(
|
|||
year = youtubeAlbum.year,
|
||||
authorsText = youtubeAlbum.authors?.joinToString("") { it.name },
|
||||
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()
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
|
||||
val songs by remember(browseId) {
|
||||
Database.artistSongs(browseId)
|
||||
Database.albumSongs(browseId)
|
||||
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
|
@ -111,12 +123,6 @@ fun AlbumScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val (songThumbnailSizeDp, songThumbnailSizePx) = remember {
|
||||
density.run {
|
||||
54.dp to 54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = 72.dp),
|
||||
|
@ -155,19 +161,9 @@ fun AlbumScreen(
|
|||
text = "Enqueue",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
albumResult
|
||||
?.getOrNull()
|
||||
?.let { album ->
|
||||
// album.items
|
||||
// ?.mapNotNull { song ->
|
||||
// song.toMediaItem(browseId, album)
|
||||
// }
|
||||
// ?.let { mediaItems ->
|
||||
// binder?.player?.enqueue(
|
||||
// mediaItems
|
||||
// )
|
||||
// }
|
||||
}
|
||||
binder?.player?.enqueue(
|
||||
songs.map(DetailedSong::asMediaItem)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -259,16 +255,9 @@ fun AlbumScreen(
|
|||
modifier = Modifier
|
||||
.clickable {
|
||||
binder?.stopRadio()
|
||||
// playlistOrAlbum.items
|
||||
// ?.shuffled()
|
||||
// ?.mapNotNull { song ->
|
||||
// song.toMediaItem(browseId, playlistOrAlbum)
|
||||
// }
|
||||
// ?.let { mediaItems ->
|
||||
// binder?.player?.forcePlayFromBeginning(
|
||||
// mediaItems
|
||||
// )
|
||||
// }
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.shuffled().map(DetailedSong::asMediaItem)
|
||||
)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(
|
||||
|
@ -286,15 +275,9 @@ fun AlbumScreen(
|
|||
modifier = Modifier
|
||||
.clickable {
|
||||
binder?.stopRadio()
|
||||
// playlistOrAlbum.items
|
||||
// ?.mapNotNull { song ->
|
||||
// song.toMediaItem(browseId, playlistOrAlbum)
|
||||
// }
|
||||
// ?.let { mediaItems ->
|
||||
// binder?.player?.forcePlayFromBeginning(
|
||||
// mediaItems
|
||||
// )
|
||||
// }
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.map(DetailedSong::asMediaItem)
|
||||
)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(
|
||||
|
@ -308,405 +291,36 @@ fun AlbumScreen(
|
|||
}
|
||||
}
|
||||
} ?: albumResult?.exceptionOrNull()?.let { throwable ->
|
||||
LoadingOrError(
|
||||
errorMessage = throwable.javaClass.canonicalName,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LoadingOrError(errorMessage = throwable.javaClass.canonicalName)
|
||||
} ?: LoadingOrError()
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = playlistOrAlbum.valueOrNull?.items ?: emptyList(),
|
||||
items = songs,
|
||||
key = { _, song -> song.song.id },
|
||||
contentType = { _, song -> song }
|
||||
) { index, song ->
|
||||
SongItem(
|
||||
title = song.info.name,
|
||||
authors = (song.authors ?: playlistOrAlbum.valueOrNull?.authors)?.joinToString("") { it.name },
|
||||
durationText = song.durationText,
|
||||
title = song.song.title,
|
||||
authors = song.song.artistsText ?: albumResult?.getOrNull()?.authorsText,
|
||||
durationText = song.song.durationText,
|
||||
onClick = {
|
||||
binder?.stopRadio()
|
||||
playlistOrAlbum.valueOrNull?.items?.mapNotNull { song ->
|
||||
song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!)
|
||||
}?.let { mediaItems ->
|
||||
binder?.player?.forcePlayAtIndex(mediaItems, index)
|
||||
}
|
||||
binder?.player?.forcePlayAtIndex(songs.map(DetailedSong::asMediaItem), 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)
|
||||
)
|
||||
}
|
||||
BasicText(
|
||||
text = "${index + 1}",
|
||||
style = typography.xs.secondary.bold.center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
)
|
||||
},
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(
|
||||
mediaItem = song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!)
|
||||
?: return@SongItem,
|
||||
mediaItem = song.asMediaItem,
|
||||
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
|
||||
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 {
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -46,9 +46,9 @@ fun Database.insert(mediaItem: MediaItem): Song {
|
|||
authorsText = null,
|
||||
thumbnailUrl = null,
|
||||
shareUrl = null,
|
||||
).also(::insert)
|
||||
).also(::upsert)
|
||||
|
||||
insert(
|
||||
upsert(
|
||||
SongAlbumMap(
|
||||
songId = song.id,
|
||||
albumId = albumId,
|
||||
|
|
|
@ -690,7 +690,7 @@ object YouTube {
|
|||
}
|
||||
|
||||
suspend fun browse2(browseId: String): Result<BrowseResponse> {
|
||||
return runCatching {
|
||||
return runCatching<YouTube, BrowseResponse> {
|
||||
client.post("/youtubei/v1/browse") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
|
|
Loading…
Reference in a new issue