From 5a4cb5cc16caa0965fb8e9cc0d8321dd51ba8484 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 17:21:00 +0000 Subject: [PATCH] fix(music): apply avatar+chip pattern to loading and error covers too The previous fix only ported the no-cover branch. Loading and error fallbacks were still rendering banner-only with the chip floating alone, hiding the author's avatar entirely whenever a cover URL was set but hadn't loaded. Drop the MyAsyncImage wrapper here and drive SubcomposeAsyncImage directly so every painter state picks its own overlay: - Success: real cover + floating chip alone (no avatar to collide with) - Loading: blurred banner + avatar+chip side-by-side - Error / no image: banner + avatar+chip side-by-side --- .../amethyst/ui/note/types/MusicPlaylist.kt | 117 ++++++++++++------ 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index 3a5ff4095d..d7da822f83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -40,6 +41,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,6 +55,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -71,8 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.WatchAuthor import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageBanner -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageBannerBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -279,45 +283,82 @@ private fun MusicPlaylistCover( Box(imageModifier) { if (image != null) { - MyAsyncImage( - imageUrl = image, + // Driving SubcomposeAsyncImage directly (rather than going through MyAsyncImage) + // so each painter state can choose its own overlay: only `Success` floats the + // chip alone over the loaded artwork; `Loading` and `Error` fall back to the + // banner with the author's avatar + chip side-by-side — same treatment as the + // no-image case below — so the chip never sits on top of the avatar. + SubcomposeAsyncImage( + model = image, contentDescription = stringRes(R.string.preview_card_image_for, image), contentScale = ContentScale.Crop, - mainImageModifier = Modifier.fillMaxSize(), - loadedImageModifier = imageModifier, - accountViewModel = accountViewModel, - onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, - onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, - ) + modifier = Modifier.fillMaxSize(), + ) { + val state by painter.state.collectAsState() + when (state) { + is AsyncImagePainter.State.Success -> { + SubcomposeAsyncImageContent(imageModifier) + FloatingTrackCountChip(trackCount) + } - // When a real cover loads, the chip floats on top of the artwork in the - // bottom-left corner — no avatar to collide with. - TrackCountChip( - trackCount = trackCount, + is AsyncImagePainter.State.Error -> { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) + } + + else -> { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = true) + } + } + } + } else { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) + } + } +} + +@Composable +private fun BoxScope.FloatingTrackCountChip(trackCount: Int) { + TrackCountChip( + trackCount = trackCount, + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + ) +} + +/** + * Author-banner cover used whenever the playlist has no album artwork to show — either + * because the playlist doesn't carry an `image` tag, or because the configured URL is + * still loading or failed to load. Lays out the author's avatar and the track-count chip + * side-by-side at the bottom-left so the chip never overlaps the avatar. + */ +@Composable +private fun BannerWithAuthorChip( + note: Note, + trackCount: Int, + imageModifier: Modifier, + accountViewModel: AccountViewModel, + blurred: Boolean, +) { + Box(imageModifier) { + if (blurred) { + DefaultImageBannerBackground(note, accountViewModel, imageModifier) + } else { + DefaultImageBanner(note, accountViewModel, imageModifier) + } + + WatchAuthor(baseNote = note, accountViewModel = accountViewModel) { author -> + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .align(Alignment.BottomStart) - .padding(12.dp), - ) - } else { - // No cover artwork: render the author's banner alone (DefaultImageBanner instead - // of DefaultImageHeader, which would bake its own avatar into BottomStart and - // collide with the chip). Then place the avatar and chip in a single bottom-row - // so they sit side-by-side instead of stacking. - DefaultImageBanner(note, accountViewModel, imageModifier) - - WatchAuthor(baseNote = note, accountViewModel = accountViewModel) { author -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .align(Alignment.BottomStart) - .padding(10.dp), - ) { - BaseUserPicture(author, Size55dp, accountViewModel, Modifier) - Spacer(Modifier.padding(start = 8.dp)) - TrackCountChip(trackCount = trackCount) - } + .padding(10.dp), + ) { + BaseUserPicture(author, Size55dp, accountViewModel, Modifier) + Spacer(Modifier.padding(start = 8.dp)) + TrackCountChip(trackCount = trackCount) } } } @@ -524,9 +565,9 @@ private fun formatTrackDuration(seconds: Int): String { // @Preview composables. Constructs a MusicPlaylistEvent plus a handful of // MusicTrackEvents it references, pushes them through LocalCache, then // renders the same code path the production feed uses. The cover image URL -// is unreachable on purpose so MyAsyncImage falls back to the deterministic -// DefaultImageHeader robohash — that's what users see while the real cover -// is still loading. +// is unreachable on purpose so the cover falls back to the deterministic +// banner + author-avatar layout — that's what users see while the real +// cover is still loading. // --------------------------------------------------------------------------- private const val PREVIEW_PLAYLIST_PUBKEY = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"