diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Items.kt b/app/src/main/java/com/zionhuang/music/ui/component/Items.kt index 4fc1a6e37..915d06585 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/Items.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/Items.kt @@ -1,7 +1,6 @@ package com.zionhuang.music.ui.component import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween import androidx.compose.animation.expandIn import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -9,6 +8,7 @@ import androidx.compose.animation.shrinkOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Column @@ -36,32 +36,27 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEachIndexed -import androidx.core.graphics.drawable.toBitmapOrNull import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING import androidx.media3.exoplayer.offline.Download.STATE_QUEUED import coil.compose.AsyncImage -import coil.compose.AsyncImagePainter -import coil.request.ImageRequest import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem import com.zionhuang.innertube.models.ArtistItem @@ -83,7 +78,6 @@ import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.theme.extractThemeColor import com.zionhuang.music.utils.joinByBullet import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.utils.reportException @@ -95,6 +89,7 @@ import kotlinx.coroutines.withContext const val ActiveBoxAlpha = 0.6f +// Basic list item @Composable inline fun ListItem( modifier: Modifier = Modifier, @@ -139,6 +134,7 @@ inline fun ListItem( } } +// merge badges and subtitle text and pass to basic list item @Composable fun ListItem( modifier: Modifier = Modifier, @@ -170,11 +166,10 @@ fun ListItem( @Composable fun GridItem( modifier: Modifier = Modifier, - title: String, - subtitle: String, + title: @Composable () -> Unit, + subtitle: @Composable () -> Unit, badges: @Composable RowScope.() -> Unit = {}, thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, - thumbnailShape: Shape, thumbnailRatio: Float = 1f, fillMaxWidth: Boolean = false, ) { @@ -196,13 +191,34 @@ fun GridItem( Modifier.height(GridThumbnailHeight) } .aspectRatio(thumbnailRatio) - .clip(thumbnailShape) ) { thumbnailContent() } Spacer(modifier = Modifier.height(6.dp)) + title() + + Row(verticalAlignment = Alignment.CenterVertically) { + badges() + + subtitle() + } + } +} + +@Composable +fun GridItem( + modifier: Modifier = Modifier, + title: String, + subtitle: String, + badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, + thumbnailRatio: Float = 1f, + fillMaxWidth: Boolean = false, +) = GridItem( + modifier = modifier, + title = { Text( text = title, style = MaterialTheme.typography.bodyLarge, @@ -212,20 +228,20 @@ fun GridItem( textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth() ) - - Row(verticalAlignment = Alignment.CenterVertically) { - badges() - - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } -} + }, + subtitle = { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + thumbnailContent = thumbnailContent, + thumbnailRatio = thumbnailRatio, + fillMaxWidth = fillMaxWidth +) @Composable fun SongListItem( @@ -237,44 +253,14 @@ fun SongListItem( showDownloadIcon: Boolean = true, badges: @Composable RowScope.() -> Unit = { if (showLikedIcon && song.song.liked) { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Favorite() } if (showInLibraryIcon && song.song.inLibrary != null) { - Icon( - painter = painterResource(R.drawable.library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Library() } if (showDownloadIcon) { val download by LocalDownloadUtil.current.getDownload(song.id).collectAsState(initial = null) - when (download?.state) { - STATE_COMPLETED -> Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - - STATE_QUEUED, STATE_DOWNLOADING -> CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier - .size(16.dp) - .padding(end = 2.dp) - ) - - else -> {} - } + Icon.Download(download?.state) } }, isActive: Boolean = false, @@ -288,43 +274,14 @@ fun SongListItem( ), badges = badges, thumbnailContent = { - Box( - contentAlignment = Alignment.Center, + ItemThumbnail( + thumbnailUrl = song.song.thumbnailUrl, + albumIndex = albumIndex, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.size(ListThumbnailSize) - ) { - if (albumIndex != null) { - AnimatedVisibility( - visible = !isActive, - enter = fadeIn() + expandIn(expandFrom = Alignment.Center), - exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() - ) { - Text( - text = albumIndex.toString(), - style = MaterialTheme.typography.labelLarge - ) - } - } else { - AsyncImage( - model = song.song.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - } - - PlayingIndicatorBox( - isActive = isActive, - playWhenReady = isPlaying, - color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, - modifier = Modifier - .fillMaxSize() - .background( - color = if (albumIndex != null) Color.Transparent else Color.Black.copy(alpha = ActiveBoxAlpha), - shape = RoundedCornerShape(ThumbnailCornerRadius) - ) - ) - } + ) }, trailingContent = trailingContent, modifier = modifier @@ -339,44 +296,14 @@ fun SongGridItem( showDownloadIcon: Boolean = true, badges: @Composable RowScope.() -> Unit = { if (showLikedIcon && song.song.liked) { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Favorite() } if (showInLibraryIcon && song.song.inLibrary != null) { - Icon( - painter = painterResource(R.drawable.library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Library() } if (showDownloadIcon) { val download by LocalDownloadUtil.current.getDownload(song.id).collectAsState(initial = null) - when (download?.state) { - STATE_COMPLETED -> Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - - STATE_QUEUED, STATE_DOWNLOADING -> CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier - .size(16.dp) - .padding(end = 2.dp) - ) - - else -> {} - } + Icon.Download(download?.state) } }, isActive: Boolean = false, @@ -390,32 +317,14 @@ fun SongGridItem( ), badges = badges, thumbnailContent = { - Box( - contentAlignment = Alignment.Center, + ItemThumbnail( + thumbnailUrl = song.song.thumbnailUrl, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.size(GridThumbnailHeight) - ) { - AsyncImage( - model = song.song.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - PlayingIndicatorBox( - isActive = isActive, - playWhenReady = isPlaying, - color = Color.White, - modifier = Modifier - .fillMaxSize() - .background( - color = Color.Black.copy(alpha = ActiveBoxAlpha), - shape = RoundedCornerShape(ThumbnailCornerRadius) - ) - ) - } + ) }, - thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), fillMaxWidth = fillMaxWidth, modifier = modifier ) @@ -426,14 +335,7 @@ fun ArtistListItem( modifier: Modifier = Modifier, badges: @Composable RowScope.() -> Unit = { if (artist.artist.bookmarkedAt != null) { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Favorite() } }, trailingContent: @Composable RowScope.() -> Unit = {}, @@ -460,14 +362,7 @@ fun ArtistGridItem( modifier: Modifier = Modifier, badges: @Composable RowScope.() -> Unit = { if (artist.artist.bookmarkedAt != null) { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Favorite() } }, fillMaxWidth: Boolean = false, @@ -480,10 +375,11 @@ fun ArtistGridItem( model = artist.artist.thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) ) }, - thumbnailShape = CircleShape, fillMaxWidth = fillMaxWidth, modifier = modifier ) @@ -513,49 +409,19 @@ fun AlbumListItem( LaunchedEffect(songs) { if (songs.isEmpty()) return@LaunchedEffect downloadUtil.downloads.collect { downloads -> - downloadState = - if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) - STATE_COMPLETED - else if (songs.all { - downloads[it.id]?.state == STATE_QUEUED - || downloads[it.id]?.state == STATE_DOWNLOADING - || downloads[it.id]?.state == STATE_COMPLETED - }) - STATE_DOWNLOADING - else - Download.STATE_STOPPED + downloadState = when { + songs.all { downloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED + songs.all { downloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING, STATE_COMPLETED) } -> STATE_DOWNLOADING + else -> Download.STATE_STOPPED + } } } if (showLikedIcon && album.album.bookmarkedAt != null) { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Favorite() } - when (downloadState) { - STATE_COMPLETED -> Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - - STATE_DOWNLOADING -> CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier - .size(16.dp) - .padding(end = 2.dp) - ) - - else -> {} - } + Icon.Download(downloadState) }, isActive: Boolean = false, isPlaying: Boolean = false, @@ -569,40 +435,12 @@ fun AlbumListItem( ), badges = badges, thumbnailContent = { - val database = LocalDatabase.current - val coroutineScope = rememberCoroutineScope() - - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(album.album.thumbnailUrl) - .allowHardware(false) - .build(), - contentDescription = null, - onState = { state -> - if (album.album.themeColor == null && state is AsyncImagePainter.State.Success) { - coroutineScope.launch(Dispatchers.IO) { - state.result.drawable.toBitmapOrNull()?.extractThemeColor()?.toArgb()?.let { color -> - database.query { - update(album.album.copy(themeColor = color)) - } - } - } - } - }, - modifier = Modifier - .size(ListThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - PlayingIndicatorBox( + ItemThumbnail( + thumbnailUrl = album.album.thumbnailUrl, isActive = isActive, - playWhenReady = isPlaying, - modifier = Modifier - .size(ListThumbnailSize) - .background( - color = Color.Black.copy(alpha = ActiveBoxAlpha), - shape = RoundedCornerShape(ThumbnailCornerRadius) - ) + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = Modifier.size(ListThumbnailSize) ) }, trailingContent = trailingContent, @@ -634,49 +472,19 @@ fun AlbumGridItem( LaunchedEffect(songs) { if (songs.isEmpty()) return@LaunchedEffect downloadUtil.downloads.collect { downloads -> - downloadState = - if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) - STATE_COMPLETED - else if (songs.all { - downloads[it.id]?.state == STATE_QUEUED - || downloads[it.id]?.state == STATE_DOWNLOADING - || downloads[it.id]?.state == STATE_COMPLETED - }) - STATE_DOWNLOADING - else - Download.STATE_STOPPED + downloadState = when { + songs.all { downloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED + songs.all { downloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING, STATE_COMPLETED) } -> STATE_DOWNLOADING + else -> Download.STATE_STOPPED + } } } if (album.album.bookmarkedAt != null) { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Favorite() } - when (downloadState) { - STATE_COMPLETED -> Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - - STATE_DOWNLOADING -> CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier - .size(16.dp) - .padding(end = 2.dp) - ) - - else -> {} - } + Icon.Download(downloadState) }, isActive: Boolean = false, isPlaying: Boolean = false, @@ -686,86 +494,34 @@ fun AlbumGridItem( subtitle = album.artists.joinToString { it.name }, badges = badges, thumbnailContent = { - AsyncImage( - model = album.album.thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return@GridItem - AnimatedVisibility( - visible = isActive, - enter = fadeIn(tween(500)), - exit = fadeOut(tween(500)) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - .background( - color = Color.Black.copy(alpha = ActiveBoxAlpha), - shape = RoundedCornerShape(ThumbnailCornerRadius) - ) - ) { - if (isPlaying) { - PlayingIndicator( - color = Color.White, - modifier = Modifier.height(24.dp) - ) - } else { - Icon( - painter = painterResource(R.drawable.play), - contentDescription = null, - tint = Color.White - ) - } - } - } + ItemThumbnail( + thumbnailUrl = album.album.thumbnailUrl, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + ) - AnimatedVisibility( + AlbumPlayButton( visible = !isActive, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(8.dp) - ) { - val database = LocalDatabase.current - val playerConnection = LocalPlayerConnection.current ?: return@AnimatedVisibility - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = ActiveBoxAlpha)) - .clickable { - coroutineScope.launch { - database - .albumWithSongs(album.id) - .first() - ?.songs - ?.map { it.toMediaItem() } - ?.let { - playerConnection.playQueue( - ListQueue( - title = album.album.title, - items = it - ) - ) - } + onClick = { + coroutineScope.launch { + database.albumWithSongs(album.id).first()?.songs + ?.map { it.toMediaItem() } + ?.let { + playerConnection.playQueue( + ListQueue( + title = album.album.title, + items = it + ) + ) } - } - ) { - Icon( - painter = painterResource(R.drawable.play), - contentDescription = null, - tint = Color.White - ) + } } - } + ) }, - thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), fillMaxWidth = fillMaxWidth, modifier = modifier ) @@ -779,48 +535,22 @@ fun PlaylistListItem( title = playlist.playlist.name, subtitle = pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount), thumbnailContent = { - when (playlist.thumbnails.size) { - 0 -> Icon( - painter = painterResource(R.drawable.queue_music), - contentDescription = null, - modifier = Modifier.size(ListThumbnailSize) - ) - - 1 -> AsyncImage( - model = playlist.thumbnails[0], - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(ListThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - else -> Box( - modifier = Modifier - .size(ListThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) { - listOf( - Alignment.TopStart, - Alignment.TopEnd, - Alignment.BottomStart, - Alignment.BottomEnd - ).fastForEachIndexed { index, alignment -> - AsyncImage( - model = playlist.thumbnails.getOrNull(index), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .align(alignment) - .size(ListThumbnailSize / 2) - ) - } - } - } - }, - trailingContent = trailingContent, - modifier = modifier -) + PlaylistThumbnail( + thumbnails = playlist.thumbnails, + size = ListThumbnailSize, + placeHolder = { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + modifier = Modifier.size(ListThumbnailSize) + ) + }, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + }, + trailingContent = trailingContent, + modifier = modifier +) @Composable fun PlaylistGridItem( @@ -834,49 +564,22 @@ fun PlaylistGridItem( badges = badges, thumbnailContent = { val width = maxWidth - when (playlist.thumbnails.size) { - 0 -> Icon( - painter = painterResource(R.drawable.queue_music), - contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.8f), - modifier = Modifier - .size(width / 2) - .align(Alignment.Center) - ) - - 1 -> AsyncImage( - model = playlist.thumbnails[0], - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(width) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - else -> Box( - modifier = Modifier - .size(width) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) { - listOf( - Alignment.TopStart, - Alignment.TopEnd, - Alignment.BottomStart, - Alignment.BottomEnd - ).fastForEachIndexed { index, alignment -> - AsyncImage( - model = playlist.thumbnails.getOrNull(index), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .align(alignment) - .size(width / 2) - ) - } - } - } + PlaylistThumbnail( + thumbnails = playlist.thumbnails, + size = width, + placeHolder = { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier + .size(width / 2) + .align(Alignment.Center) + ) + }, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) }, - thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), fillMaxWidth = fillMaxWidth, modifier = modifier ) @@ -895,23 +598,12 @@ fun MediaMetadataListItem( makeTimeString(mediaMetadata.duration * 1000L) ), thumbnailContent = { - AsyncImage( - model = mediaMetadata.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .size(ListThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - PlayingIndicatorBox( + ItemThumbnail( + thumbnailUrl = mediaMetadata.thumbnailUrl, isActive = isActive, - playWhenReady = isPlaying, - modifier = Modifier - .size(ListThumbnailSize) - .background( - color = Color.Black.copy(alpha = ActiveBoxAlpha), - shape = RoundedCornerShape(ThumbnailCornerRadius) - ) + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = Modifier.size(ListThumbnailSize) ) }, trailingContent = trailingContent, @@ -931,53 +623,17 @@ fun YouTubeListItem( if (item is SongItem && song?.song?.liked == true || item is AlbumItem && album?.album?.bookmarkedAt != null ) { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Favorite() } if (item.explicit) { - Icon( - painter = painterResource(R.drawable.explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Explicit() } if (item is SongItem && song?.song?.inLibrary != null) { - Icon( - painter = painterResource(R.drawable.library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Library() } if (item is SongItem) { val downloads by LocalDownloadUtil.current.downloads.collectAsState() - when (downloads[item.id]?.state) { - STATE_COMPLETED -> Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - - STATE_QUEUED, STATE_DOWNLOADING -> CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier - .size(16.dp) - .padding(end = 2.dp) - ) - - else -> {} - } + Icon.Download(downloads[item.id]?.state) } }, isActive: Boolean = false, @@ -993,44 +649,14 @@ fun YouTubeListItem( }, badges = badges, thumbnailContent = { - Box( - contentAlignment = Alignment.Center, + ItemThumbnail( + thumbnailUrl = item.thumbnail, + albumIndex = albumIndex, + isActive = isActive, + isPlaying = isPlaying, + shape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.size(ListThumbnailSize) - ) { - val thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) - if (albumIndex != null) { - AnimatedVisibility( - visible = !isActive, - enter = fadeIn() + expandIn(expandFrom = Alignment.Center), - exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() - ) { - Text( - text = albumIndex.toString(), - style = MaterialTheme.typography.labelLarge - ) - } - } else { - AsyncImage( - model = item.thumbnail, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(thumbnailShape) - ) - } - - PlayingIndicatorBox( - isActive = isActive, - playWhenReady = isPlaying, - color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, - modifier = Modifier - .fillMaxSize() - .background( - color = if (albumIndex != null) Color.Transparent else Color.Black.copy(alpha = ActiveBoxAlpha), - shape = thumbnailShape - ) - ) - } + ) }, trailingContent = trailingContent, modifier = modifier @@ -1049,206 +675,281 @@ fun YouTubeGridItem( if (item is SongItem && song?.song?.liked == true || item is AlbumItem && album?.album?.bookmarkedAt != null ) { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Favorite() } if (item.explicit) { - Icon( - painter = painterResource(R.drawable.explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Explicit() } if (item is SongItem && song?.song?.inLibrary != null) { - Icon( - painter = painterResource(R.drawable.library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Icon.Library() } if (item is SongItem) { val downloads by LocalDownloadUtil.current.downloads.collectAsState() - when (downloads[item.id]?.state) { - STATE_COMPLETED -> Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - - STATE_DOWNLOADING -> CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier - .size(16.dp) - .padding(end = 2.dp) - ) - - else -> {} - } + Icon.Download(downloads[item.id]?.state) } }, isActive: Boolean = false, isPlaying: Boolean = false, fillMaxWidth: Boolean = false, -) { - val thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) - val thumbnailRatio = if (item is SongItem) 16f / 9 else 1f +) = GridItem( + title = { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = if (item is ArtistItem) TextAlign.Center else TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + }, + subtitle = { + val subtitle = when (item) { + is SongItem -> joinByBullet(item.artists.joinToString { it.name }, makeTimeString(item.duration?.times(1000L))) + is AlbumItem -> joinByBullet(item.artists?.joinToString { it.name }, item.year?.toString()) + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) + } - Column( - modifier = if (fillMaxWidth) { - modifier - .padding(12.dp) - .fillMaxWidth() - } else { - modifier - .padding(12.dp) - .width(GridThumbnailHeight * thumbnailRatio) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } + }, + badges = badges, + thumbnailContent = { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return@GridItem + + ItemThumbnail( + thumbnailUrl = item.thumbnail, + isActive = isActive, + isPlaying = isPlaying, + shape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius), + ) + + AlbumPlayButton( + visible = item is AlbumItem && !isActive, + onClick = { + coroutineScope?.launch(Dispatchers.IO) { + var songs = database + .albumWithSongs(item.id) + .first()?.songs?.map { it.toMediaItem() } + if (songs == null) { + YouTube.album(item.id).onSuccess { albumPage -> + database.transaction { + insert(albumPage) + } + songs = albumPage.songs.map { it.toMediaItem() } + }.onFailure { + reportException(it) + } + } + songs?.let { + withContext(Dispatchers.Main) { + playerConnection.playQueue( + ListQueue( + title = item.title, + items = it + ) + ) + } + } + } + } + ) + }, + thumbnailRatio = if (item is SongItem) 16f / 9 else 1f, + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + +@Composable +fun ItemThumbnail( + thumbnailUrl: String?, + isActive: Boolean, + isPlaying: Boolean, + shape: Shape, + modifier: Modifier = Modifier, + albumIndex: Int? = null, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier ) { - Box( - modifier = if (fillMaxWidth) { - Modifier.fillMaxWidth() - } else { - Modifier.height(GridThumbnailHeight) + if (albumIndex != null) { + AnimatedVisibility( + visible = !isActive, + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() + ) { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge + ) } - .aspectRatio(thumbnailRatio) - .clip(thumbnailShape) - ) { + } else { AsyncImage( - model = item.thumbnail, + model = thumbnailUrl, contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .clip(shape) ) + } - androidx.compose.animation.AnimatedVisibility( - visible = isActive, - enter = fadeIn(tween(500)), - exit = fadeOut(tween(500)) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - .background( - color = Color.Black.copy(alpha = ActiveBoxAlpha), - shape = thumbnailShape - ) - ) { - if (isPlaying) { - PlayingIndicator( - color = Color.White, - modifier = Modifier.height(24.dp) - ) - } else { - Icon( - painter = painterResource(R.drawable.play), - contentDescription = null, - tint = Color.White - ) - } - } - } + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, + modifier = Modifier + .fillMaxSize() + .background( + color = if (albumIndex != null) Color.Transparent else Color.Black.copy(alpha = ActiveBoxAlpha), + shape = shape + ) + ) + } +} + +@Composable +fun PlaylistThumbnail( + thumbnails: List, + size: Dp, + placeHolder: @Composable () -> Unit, + shape: Shape, +) { + when (thumbnails.size) { + 0 -> placeHolder() - androidx.compose.animation.AnimatedVisibility( - visible = item is AlbumItem && !isActive, - enter = fadeIn(), - exit = fadeOut(), + 1 -> AsyncImage( + model = thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(size) + .clip(shape) + ) + + else -> Box( + modifier = Modifier + .size(size) + .clip(shape) + ) { + Box( modifier = Modifier - .align(Alignment.BottomEnd) - .padding(8.dp) + .fillMaxSize() + .clip(shape) ) { - val database = LocalDatabase.current - val playerConnection = LocalPlayerConnection.current ?: return@AnimatedVisibility - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = ActiveBoxAlpha)) - .clickable { - coroutineScope?.launch(Dispatchers.IO) { - var songs = database - .albumWithSongs(item.id) - .first()?.songs?.map { it.toMediaItem() } - if (songs == null) { - YouTube - .album(item.id) - .onSuccess { albumPage -> - database.transaction { - insert(albumPage) - } - songs = albumPage.songs.map { it.toMediaItem() } - } - .onFailure { - reportException(it) - } - } - songs?.let { - withContext(Dispatchers.Main) { - playerConnection.playQueue( - ListQueue( - title = item.title, - items = it - ) - ) - } - } - } - } - ) { - Icon( - painter = painterResource(R.drawable.play), + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = thumbnails.getOrNull(index), contentDescription = null, - tint = Color.White + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(size / 2) ) } } } + } +} - Spacer(modifier = Modifier.height(6.dp)) +@Composable +fun BoxScope.AlbumPlayButton( + visible: Boolean, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = ActiveBoxAlpha)) + .clickable(onClick = onClick) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White + ) + } + } +} - Text( - text = item.title, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = if (item is ArtistItem) TextAlign.Center else TextAlign.Start, - modifier = Modifier.fillMaxWidth() +private object Icon { + @Composable + fun Favorite() { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) ) + } - Row(verticalAlignment = Alignment.CenterVertically) { - badges() + @Composable + fun Library() { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } - val subtitle = when (item) { - is SongItem -> joinByBullet(item.artists.joinToString { it.name }, makeTimeString(item.duration?.times(1000L))) - is AlbumItem -> joinByBullet(item.artists?.joinToString { it.name }, item.year?.toString()) - is ArtistItem -> null - is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) - } + @Composable + fun Download(state: Int?) { + when (state) { + STATE_COMPLETED -> Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) - if (subtitle != null) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } + STATE_QUEUED, STATE_DOWNLOADING -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(end = 2.dp) + ) + + else -> {} } } + + @Composable + fun Explicit() { + Icon( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }