feat(android): shared TrackCoverThumb + cover art on Playlist/History rows

Factors the cover-thumbnail composable out of LikedTab into a shared
widget so PlaylistDetail tracks and HistoryTab rows can reuse it.
Same placeholder-URL trick — BaseUrlInterceptor rewrites the host
on every request and Coil shares the OkHttp client.

New:
  - shared/widgets/TrackCoverThumb.kt — composable with size +
    coverUrl + contentDescription params. Defaults to 48dp; passes
    the size through to both the clip and the fallback icon so
    callers can scale up/down without re-implementing.

Modified:
  - models/Playlist.kt — adds `coverUrl` derived prop on
    PlaylistTrackRef. Same `/api/albums/{id}/cover` pattern as
    TrackRef.coverUrl; empty when albumId is null (the
    track-removed-from-library case).
  - likes/ui/LikedTab.kt — drops the local TrackCoverThumb copy,
    uses the shared one. Removes 8 now-unused imports.
  - playlists/ui/PlaylistDetailScreen.kt — adds cover thumb to track
    rows. Drops the leading position number (1, 2, 3...) since row
    order already conveys position and the cover fills that visual
    slot.
  - history/ui/HistoryTab.kt — adds cover thumb leading each
    history row. Vertical padding tightened 10dp → 8dp to match the
    other thumb-bearing rows.

Search track results stay deferred — its TextRow handles
artists/albums/tracks generically, splitting it for per-track
covers is a bigger change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 08:08:27 -04:00
parent 41c6258b05
commit 2c640b897a
5 changed files with 89 additions and 43 deletions
@@ -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,
@@ -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) {
@@ -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()
}
@@ -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(
@@ -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