feat(android): similar-artists strip + top-tracks on artist detail
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
@@ -35,9 +36,26 @@ interface LibraryApi {
|
|||||||
@GET("api/artists/{id}/tracks")
|
@GET("api/artists/{id}/tracks")
|
||||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
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}")
|
@GET("api/albums/{id}")
|
||||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||||
|
|
||||||
@GET("api/library/shuffle")
|
@GET("api/library/shuffle")
|
||||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
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> =
|
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||||
api.getArtistTracks(id).map { it.toDomain() }
|
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
|
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||||
|
|||||||
+105
-7
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||||
|
|
||||||
package com.fabledsword.minstrel.library.ui
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
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.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.Play
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
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.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.models.albumCoverPath
|
import com.fabledsword.minstrel.models.albumCoverPath
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
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.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailScreen(
|
fun ArtistDetailScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||||
|
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -105,12 +114,19 @@ fun ArtistDetailScreen(
|
|||||||
is ArtistDetailUiState.Success -> {
|
is ArtistDetailUiState.Success -> {
|
||||||
val artistLiked by viewModel.artistLiked
|
val artistLiked by viewModel.artistLiked
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
|
val playerState by playerViewModel.uiState
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
ArtistBody(
|
ArtistBody(
|
||||||
detail = s.detail,
|
state = s,
|
||||||
artistLiked = artistLiked,
|
artistLiked = artistLiked,
|
||||||
|
playingTrackId = playerState.currentTrack?.id,
|
||||||
onPlay = viewModel::playArtist,
|
onPlay = viewModel::playArtist,
|
||||||
onToggleLike = viewModel::toggleLikeArtist,
|
onToggleLike = viewModel::toggleLikeArtist,
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
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
|
@Composable
|
||||||
private fun ArtistBody(
|
private fun ArtistBody(
|
||||||
detail: ArtistDetailRef,
|
state: ArtistDetailUiState.Success,
|
||||||
artistLiked: Boolean,
|
artistLiked: Boolean,
|
||||||
|
playingTrackId: String?,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
|
onTopTrackClick: (Int) -> Unit,
|
||||||
|
onSimilarArtistClick: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val detail = state.detail
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -149,18 +169,96 @@ private fun ArtistBody(
|
|||||||
onToggleLike = onToggleLike,
|
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()) {
|
if (detail.albums.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
EmptyAlbumsHint()
|
EmptyAlbumsHint()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(items = detail.albums, key = { it.id }) { album ->
|
gridItems(items = detail.albums, key = { it.id }) { album ->
|
||||||
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
|
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
|
@Composable
|
||||||
private fun ArtistHeader(
|
private fun ArtistHeader(
|
||||||
artist: ArtistRef,
|
artist: ArtistRef,
|
||||||
@@ -270,7 +368,7 @@ private fun SkeletonArtistAlbumsGrid() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-1
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
|
|||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface ArtistDetailUiState {
|
sealed interface ArtistDetailUiState {
|
||||||
data class Loading(val seed: ArtistRef? = null) : 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
|
data class Error(val message: String) : ArtistDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val detail = repository.refreshArtistDetail(artistId)
|
val detail = repository.refreshArtistDetail(artistId)
|
||||||
internal.value = ArtistDetailUiState.Success(detail)
|
internal.value = ArtistDetailUiState.Success(detail)
|
||||||
|
loadSecondarySections()
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@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
|
* Pull every track across the artist's albums, shuffle, and start
|
||||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
* 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user