feat(android): similar-artists strip + top-tracks on artist detail
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 40s
android / Build + lint + test (push) Failing after 1m16s
test-go / integration (push) Successful in 4m29s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 22:39:54 -04:00
parent e358a92cf4
commit 12a8cfccb5
4 changed files with 188 additions and 8 deletions
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.ArtistWire
import com.fabledsword.minstrel.models.wire.TrackWire
import retrofit2.http.GET
import retrofit2.http.Path
@@ -35,9 +36,26 @@ interface LibraryApi {
@GET("api/artists/{id}/tracks")
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
@GET("api/artists/{id}/similar")
suspend fun getSimilarArtists(
@Path("id") id: String,
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
): List<ArtistWire>
@GET("api/artists/{id}/top-tracks")
suspend fun getArtistTopTracks(
@Path("id") id: String,
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
): List<TrackWire>
@GET("api/albums/{id}")
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
private companion object {
const val SIMILAR_ARTISTS_LIMIT = 12
const val TOP_TRACKS_LIMIT = 5
}
}
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
api.getArtistTracks(id).map { it.toDomain() }
/**
* Pulls related artists for the ArtistDetail "Similar artists"
* strip. Network-only — non-critical UI, callers swallow failures
* and render nothing rather than blocking the screen.
*/
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
api.getSimilarArtists(id).map { it.toDomain() }
/**
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
* panel. Network-only; tapping a row plays this exact list as a
* queue, so no persistence is needed.
*/
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
api.getArtistTopTracks(id).map { it.toDomain() }
/**
* Pulls the album detail (AlbumRef + tracks), persists both, and
* returns the in-memory snapshot. Lets the AlbumDetail screen
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade
@@ -17,7 +19,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.TrackRow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtistDetailScreen(
navController: NavHostController,
viewModel: ArtistDetailViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
@@ -105,12 +114,19 @@ fun ArtistDetailScreen(
is ArtistDetailUiState.Success -> {
val artistLiked by viewModel.artistLiked
.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState
.collectAsStateWithLifecycle()
ArtistBody(
detail = s.detail,
state = s,
artistLiked = artistLiked,
playingTrackId = playerState.currentTrack?.id,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onTopTrackClick = viewModel::playTopTracks,
onSimilarArtistClick = { id ->
navController.navigate(ArtistDetail(id))
},
)
}
}
@@ -127,12 +143,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
@Composable
private fun ArtistBody(
detail: ArtistDetailRef,
state: ArtistDetailUiState.Success,
artistLiked: Boolean,
playingTrackId: String?,
onPlay: () -> Unit,
onToggleLike: () -> Unit,
onAlbumClick: (String) -> Unit,
onTopTrackClick: (Int) -> Unit,
onSimilarArtistClick: (String) -> Unit,
) {
val detail = state.detail
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 176.dp),
modifier = Modifier.fillMaxSize(),
@@ -149,18 +169,96 @@ private fun ArtistBody(
onToggleLike = onToggleLike,
)
}
if (state.topTracks.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
TopTracksPanel(
tracks = state.topTracks,
playingTrackId = playingTrackId,
onTrackClick = onTopTrackClick,
)
}
}
if (state.similarArtists.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SimilarArtistsStrip(
artists = state.similarArtists,
onArtistClick = onSimilarArtistClick,
)
}
}
if (detail.albums.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyAlbumsHint()
}
} else {
items(items = detail.albums, key = { it.id }) { album ->
gridItems(items = detail.albums, key = { it.id }) { album ->
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
}
}
}
}
@Composable
private fun TopTracksPanel(
tracks: List<TrackRef>,
playingTrackId: String?,
onTrackClick: (Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Top tracks",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
tracks.forEachIndexed { index, track ->
TopTrackRow(
track = track,
nowPlaying = track.id == playingTrackId,
onClick = { onTrackClick(index) },
)
}
}
}
@Composable
private fun TopTrackRow(
track: TrackRef,
nowPlaying: Boolean,
onClick: () -> Unit,
) {
TrackRow(
title = track.title,
artist = track.artistName,
trackId = track.id,
onClick = onClick,
nowPlaying = nowPlaying,
trailing = {
Text(
text = formatDuration(track.durationSec, zero = "--:--"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
},
)
}
@Composable
private fun SimilarArtistsStrip(
artists: List<ArtistRef>,
onArtistClick: (String) -> Unit,
) {
HorizontalScrollRow(title = "Similar artists") {
items(items = artists, key = { it.id }) { artist ->
ArtistCard(
artist = artist,
onClick = { onArtistClick(artist.id) },
)
}
}
}
@Composable
private fun ArtistHeader(
artist: ArtistRef,
@@ -270,7 +368,7 @@ private fun SkeletonArtistAlbumsGrid() {
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
gridItems(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
}
}
@@ -314,6 +412,6 @@ private fun SeededArtistLoading(seed: ArtistRef) {
}
}
}
items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
gridItems(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
}
}
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.player.PlayerController
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface ArtistDetailUiState {
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
data class Success(
val detail: ArtistDetailRef,
val similarArtists: List<ArtistRef> = emptyList(),
val topTracks: List<TrackRef> = emptyList(),
) : ArtistDetailUiState
data class Error(val message: String) : ArtistDetailUiState
}
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
try {
val detail = repository.refreshArtistDetail(artistId)
internal.value = ArtistDetailUiState.Success(detail)
loadSecondarySections()
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
}
}
/**
* Pulls the non-critical "Similar artists" + "Top tracks" sections
* and folds them into the live Success state. Each failure is
* swallowed (logged only) — these sections render absent rather than
* blocking, so the core artist view never depends on them.
*/
private suspend fun loadSecondarySections() {
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
val current = internal.value
if (current is ArtistDetailUiState.Success) {
internal.value = current.copy(similarArtists = similar, topTracks = top)
}
}
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
try {
block()
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
emptyList()
}
/**
* Pull every track across the artist's albums, shuffle, and start
* playing. Mirrors Flutter's artist Play button — pressing Play on
@@ -118,4 +150,20 @@ class ArtistDetailViewModel @Inject constructor(
}
}
}
/**
* Plays the loaded "Top tracks" list starting at [index] — the top
* tracks are themselves the playable queue (mirrors the web client's
* sectionTracks = top-tracks behaviour). No network round-trip; the
* list is already in the Success state.
*/
fun playTopTracks(index: Int) {
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
if (tracks.isEmpty()) return
player.setQueue(
tracks = tracks,
initialIndex = index.coerceIn(0, tracks.lastIndex),
source = "artist-top:$artistId",
)
}
}