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:
+26
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
+81
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user