diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt index e870150a..557f5443 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt @@ -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 + @GET("api/artists/{id}/similar") + suspend fun getSimilarArtists( + @Path("id") id: String, + @Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT, + ): List + + @GET("api/artists/{id}/top-tracks") + suspend fun getArtistTopTracks( + @Path("id") id: String, + @Query("limit") limit: Int = TOP_TRACKS_LIMIT, + ): List + @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 + + private companion object { + const val SIMILAR_ARTISTS_LIMIT = 12 + const val TOP_TRACKS_LIMIT = 5 + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt index de6f2c48..5dc4bc69 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt @@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor( suspend fun fetchArtistTracks(id: String): List = 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 = + 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 = + api.getArtistTopTracks(id).map { it.toDomain() } + /** * Pulls the album detail (AlbumRef + tracks), persists both, and * returns the in-memory snapshot. Lets the AlbumDetail screen diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index 335cbf4f..4f73633c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -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, + 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, + 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() } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt index 9355c670..8bce5b37 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt @@ -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 = emptyList(), + val topTracks: List = 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 sectionOrEmpty(block: () -> List): List = + 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", + ) + } }