feat(android): Phase 12 — ArtistDetail screen + closes phase

Replaces the ArtistDetail-route ComingSoon stub with the real artist
view. Mirrors `flutter_client/lib/library/artist_detail_screen.dart`.

New:
  - models/ArtistDetailRef.kt — ArtistRef + albums pair.
  - library/ui/ArtistDetailViewModel.kt — VM + UiState. `playArtist()`
    fires GET /api/artists/{id}/tracks and dumps the whole list into
    the player queue (`source = "artist:$id"`). Errors are silent for
    now — the play button has no inline error affordance.
  - library/ui/ArtistDetailScreen.kt — Scaffold + back-button AppBar.
    Header is a full-width LazyVerticalGrid spanning row with
    circular avatar + name + album count + Play button; below it,
    the albums tile out as an adaptive 176dp grid using the existing
    AlbumCard.

Modified:
  - library/data/LibraryRepository.kt — refreshArtistDetail now
    returns ArtistDetailRef (mirroring AlbumDetail). Adds
    fetchArtistTracks() for the Play button.
  - nav/MinstrelNavGraph.kt — ArtistDetail route renders the real
    screen; drops the now-unused androidx.navigation.toRoute import
    (all detail routes use SavedStateHandle.toRoute via the VM now).

Closes Phase 12. Like buttons on detail headers + per-track + shuffle
mode on the player are open follow-ups but don't block MVP nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:27:50 -04:00
parent 72f36c0da8
commit de1175c478
5 changed files with 335 additions and 6 deletions
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.models.AlbumDetailRef
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.flow.Flow
@@ -59,13 +60,36 @@ class LibraryRepository @Inject constructor(
// ---- Server refreshes (sync-style writes; errors propagate) ----
/** Pulls the full artist detail (ArtistRef + albums) and persists both. */
suspend fun refreshArtistDetail(id: String) {
/**
* Pulls the full artist detail (ArtistRef + albums), persists both,
* and returns the in-memory snapshot. Lets the ArtistDetail screen
* render from the wire response without the Room round-trip.
*/
suspend fun refreshArtistDetail(id: String): ArtistDetailRef {
val wire = api.getArtistDetail(id)
artistDao.upsertAll(listOf(wire.toArtistEntity()))
albumDao.upsertAll(wire.albums.map { it.toEntity() })
return ArtistDetailRef(
artist = ArtistRef(
id = wire.id,
name = wire.name,
sortName = wire.sortName,
albumCount = wire.albumCount,
coverUrl = wire.coverUrl,
),
albums = wire.albums.map { it.toDomain() },
)
}
/**
* Pulls every track for an artist (across all their albums). Used
* by the "Play artist" button on the ArtistDetail header. No
* persistence — the response is consumed straight into a player
* queue.
*/
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
api.getArtistTracks(id).map { it.toDomain() }
/**
* Pulls the album detail (AlbumRef + tracks), persists both, and
* returns the in-memory snapshot. Lets the AlbumDetail screen
@@ -0,0 +1,213 @@
package com.fabledsword.minstrel.library.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.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.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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 androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ArrowLeft
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.models.ArtistRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtistDetailScreen(
navController: NavHostController,
viewModel: ArtistDetailViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(titleFor(state)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) {
ArtistDetailUiState.Loading -> LoadingCentered()
is ArtistDetailUiState.Error -> EmptyState(
title = "Couldn't load artist",
body = s.message,
)
is ArtistDetailUiState.Success -> ArtistBody(
detail = s.detail,
onPlay = viewModel::playArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
}
}
}
}
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
is ArtistDetailUiState.Success -> state.detail.artist.name
else -> "Artist"
}
@Composable
private fun ArtistBody(
detail: ArtistDetailRef,
onPlay: () -> Unit,
onAlbumClick: (String) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 176.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
ArtistHeader(artist = detail.artist, onPlay = onPlay)
}
if (detail.albums.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyAlbumsHint()
}
} else {
items(items = detail.albums, key = { it.id }) { album ->
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
}
}
}
}
@Composable
private fun ArtistHeader(artist: ArtistRef, onPlay: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
ArtistAvatar(artist)
Column(modifier = Modifier.weight(1f)) {
Text(
text = artist.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (artist.albumCount > 0) {
Text(
text = albumCountLine(artist),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Button(onClick = onPlay) {
Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Play")
}
}
}
@Composable
private fun ArtistAvatar(artist: ArtistRef) {
Box(
modifier = Modifier
.size(96.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (artist.coverUrl.isEmpty()) {
Icon(
imageVector = Lucide.User,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(40.dp),
)
} else {
AsyncImage(
model = artist.coverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}
private fun albumCountLine(artist: ArtistRef): String =
if (artist.albumCount == 1) "1 album" else "${artist.albumCount} albums"
@Composable
private fun EmptyAlbumsHint() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "No albums yet for this artist.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
text = "They might still be importing or the scan hasn't picked them up.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,81 @@
package com.fabledsword.minstrel.library.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface ArtistDetailUiState {
data object Loading : ArtistDetailUiState
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
data class Error(val message: String) : ArtistDetailUiState
}
@HiltViewModel
class ArtistDetailViewModel @Inject constructor(
private val repository: LibraryRepository,
private val player: PlayerController,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val route: ArtistDetail = savedStateHandle.toRoute()
val artistId: String = route.id
private val internal = MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading)
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.value = ArtistDetailUiState.Loading
try {
val detail = repository.refreshArtistDetail(artistId)
internal.value = ArtistDetailUiState.Success(detail)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = ArtistDetailUiState.Error(e.message ?: "Artist load failed")
}
}
}
/**
* Pull every track across the artist's albums and start playing.
* Network round-trip per click; fine because the user explicitly
* tapped Play and a cache lookup wouldn't be complete anyway
* until per-album hydration finishes.
*/
fun playArtist() {
viewModelScope.launch {
try {
val tracks = repository.fetchArtistTracks(artistId)
if (tracks.isNotEmpty()) {
player.setQueue(
tracks = tracks,
initialIndex = 0,
source = "artist:$artistId",
)
}
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Silent failure — the play button has no inline error
// affordance; user can retry by re-tapping. A future
// refinement surfaces this via a snackbar.
}
}
}
}
@@ -0,0 +1,12 @@
package com.fabledsword.minstrel.models
/**
* Snapshot of one artist with their album list. Returned by
* `LibraryRepository.refreshArtistDetail` so the ArtistDetail screen
* can render from the wire response without waiting for the Room
* round-trip.
*/
data class ArtistDetailRef(
val artist: ArtistRef,
val albums: List<AlbumRef>,
)
@@ -9,7 +9,6 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
@@ -19,6 +18,7 @@ import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
import com.fabledsword.minstrel.home.ui.HomeScreen
import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
@@ -109,10 +109,9 @@ private fun NavGraphBuilder.inShellDetail(
AlbumDetailScreen(navController = navController)
}
}
composable<ArtistDetail> { backStackEntry ->
val route: ArtistDetail = backStackEntry.toRoute()
composable<ArtistDetail> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Artist", "Artist detail for ${route.id} — later 5.x slice.")
ArtistDetailScreen(navController = navController)
}
}
composable<PlaylistDetail> {