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 63f2c5b6..b19771ea 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 @@ -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 = + api.getArtistTracks(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 new file mode 100644 index 00000000..3c119f26 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -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) + } +} 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 new file mode 100644 index 00000000..31680284 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt @@ -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.Loading) + val uiState: StateFlow = 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. + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistDetailRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistDetailRef.kt new file mode 100644 index 00000000..74992d36 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistDetailRef.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index edb5db37..4a6b9e78 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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 { backStackEntry -> - val route: ArtistDetail = backStackEntry.toRoute() + composable { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Artist", "Artist detail for ${route.id} — later 5.x slice.") + ArtistDetailScreen(navController = navController) } } composable {