diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt index 7dfae70a..61a1942a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -29,6 +29,7 @@ import com.fabledsword.minstrel.history.data.HistoryRepository import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -131,10 +132,11 @@ private fun HistoryRow(entry: HistoryEntry, onClick: () -> Unit) { modifier = Modifier .fillMaxWidth() .clickable(enabled = entry.track.streamUrl.isNotEmpty(), onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + TrackCoverThumb(coverUrl = entry.track.coverUrl, contentDescription = entry.track.title) Column(modifier = Modifier.weight(1f)) { Text( text = entry.track.title, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt index 1f771a75..97ce3a6a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt @@ -1,6 +1,5 @@ package com.fabledsword.minstrel.likes.ui -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,26 +11,18 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.composables.icons.lucide.Lucide -import com.composables.icons.lucide.Music import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -47,6 +38,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow +import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -217,7 +209,7 @@ private fun LikedTrackRow(track: TrackRef) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - TrackCoverThumb(track) + TrackCoverThumb(coverUrl = track.coverUrl, contentDescription = track.title) Column(modifier = Modifier.weight(1f)) { Text( text = track.title, @@ -237,33 +229,6 @@ private fun LikedTrackRow(track: TrackRef) { } } -@Composable -private fun TrackCoverThumb(track: TrackRef) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - if (track.coverUrl.isEmpty()) { - Icon( - imageVector = Lucide.Music, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) - } else { - AsyncImage( - model = track.coverUrl, - contentDescription = track.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - } -} - @Composable private fun LoadingCentered() { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt index f362462b..f1ab75ba 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt @@ -44,4 +44,17 @@ data class PlaylistTrackRef( val streamUrl: String? = null, ) { val isAvailable: Boolean get() = trackId != null + + /** + * Cover URL derived from the parent album's `/api/albums/{id}/cover` + * endpoint, using the placeholder host that `BaseUrlInterceptor` + * rewrites to the live server URL. Same mechanism as + * [TrackRef.coverUrl] — Coil shares the OkHttp client. Empty when + * the row has no album binding (rare; usually only for tracks + * whose upstream album was deleted). + */ + val coverUrl: String + get() = albumId?.takeIf { it.isNotEmpty() } + ?.let { "http://placeholder.invalid/api/albums/$it/cover" } + .orEmpty() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 88238e52..518658cb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -61,6 +61,7 @@ import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -336,11 +337,10 @@ private fun TrackRow( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "${row.position}", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.width(28.dp), + TrackCoverThumb( + coverUrl = row.coverUrl, + contentDescription = row.title, + modifier = Modifier.padding(end = 12.dp), ) Column(modifier = Modifier.weight(1f)) { Text( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackCoverThumb.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackCoverThumb.kt new file mode 100644 index 00000000..4751f738 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackCoverThumb.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Music + +/** + * Shared track-row cover thumbnail. Pulls the cover from the track's + * derived `coverUrl` (which Coil resolves through the shared OkHttp + * + BaseUrlInterceptor); falls back to a Lucide.Music glyph on a + * surfaceVariant-tinted background when the URL is empty (album + * cover hasn't loaded into the cache yet OR the track has no album + * binding). + * + * Used by LikedTab, PlaylistDetail, HistoryTab, and any other dense + * track list. Sizes default to 48dp — the four-corner radius and + * fallback-icon size scale with the outer dimension so callers can + * pass e.g. 56dp for a more prominent row without re-implementing. + */ +@Composable +fun TrackCoverThumb( + coverUrl: String, + contentDescription: String?, + modifier: Modifier = Modifier, + size: Dp = DEFAULT_SIZE, +) { + Box( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (coverUrl.isEmpty()) { + Icon( + imageVector = Lucide.Music, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(size / 2), + ) + } else { + AsyncImage( + model = coverUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } +} + +private val DEFAULT_SIZE: Dp = 48.dp