feat(android): Phase 12 — AlbumDetail screen

Replaces the AlbumDetail-route ComingSoon stub with the real album
view. Mirrors `flutter_client/lib/library/album_detail_screen.dart`.

New:
  - models/AlbumDetailRef.kt — AlbumRef + tracks pair returned by
    refreshAlbumDetail.
  - library/ui/AlbumDetailViewModel.kt — VM + UiState. Pulls album
    detail on init via LibraryRepository.refreshAlbumDetail (now
    returning AlbumDetailRef directly so we can render from the wire
    response without waiting for Room emission). `play(startTrackId)`
    builds the player queue with `source = "album:$id"` so the
    server's rotation reporter can advance it.
  - library/ui/AlbumDetailScreen.kt — Scaffold + TopAppBar with back
    button; cover + title + artist + Play/Shuffle action row; LazyColumn
    of TrackRow tiles (#, title, artist, duration; tap plays from
    that position). Shuffle currently fires the same play path —
    player-level shuffle is a follow-up once PlayerController.setQueue
    grows a shuffle flag.

Modified:
  - library/data/LibraryRepository.kt — refreshAlbumDetail now
    returns AlbumDetailRef. Existing callers (the LibraryRepositoryTest)
    ignore the return value, no breakage.
  - nav/MinstrelNavGraph.kt — AlbumDetail route renders the real
    screen; id flows via SavedStateHandle.toRoute() inside the VM,
    so the composable block is a one-liner.

ArtistDetail follows in the next commit within this phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:54:49 -04:00
parent cb4b411ab6
commit 72f36c0da8
5 changed files with 379 additions and 5 deletions
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.api.endpoints.LibraryApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
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.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
@@ -65,11 +66,30 @@ class LibraryRepository @Inject constructor(
albumDao.upsertAll(wire.albums.map { it.toEntity() })
}
/** Pulls the album detail (AlbumRef + tracks) and persists both. */
suspend fun refreshAlbumDetail(id: String) {
/**
* Pulls the album detail (AlbumRef + tracks), persists both, and
* returns the in-memory snapshot. Lets the AlbumDetail screen
* render immediately from the wire response without waiting for
* the Room round-trip.
*/
suspend fun refreshAlbumDetail(id: String): AlbumDetailRef {
val wire = api.getAlbumDetail(id)
albumDao.upsertAll(listOf(wire.toAlbumEntity()))
trackDao.upsertAll(wire.tracks.map { it.toEntity() })
return AlbumDetailRef(
album = AlbumRef(
id = wire.id,
title = wire.title,
artistId = wire.artistId,
sortTitle = wire.sortTitle,
artistName = wire.artistName,
year = wire.year,
trackCount = wire.trackCount,
durationSec = wire.durationSec,
coverUrl = wire.coverUrl,
),
tracks = wire.tracks.map { it.toDomain() },
)
}
/** Track detail — single track, mainly for the hydration queue. */
@@ -0,0 +1,274 @@
package com.fabledsword.minstrel.library.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.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.Disc3
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.models.AlbumDetailRef
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumDetailScreen(
navController: NavHostController,
viewModel: AlbumDetailViewModel = 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) {
AlbumDetailUiState.Loading -> LoadingCentered()
is AlbumDetailUiState.Error -> EmptyState(
title = "Couldn't load album",
body = s.message,
)
is AlbumDetailUiState.Success -> AlbumBody(
detail = s.detail,
onPlayAll = { viewModel.play(startTrackId = null) },
onShuffleAll = { viewModel.play(startTrackId = null) },
onTrackClick = { id -> viewModel.play(startTrackId = id) },
)
}
}
}
}
private fun titleFor(state: AlbumDetailUiState): String = when (state) {
is AlbumDetailUiState.Success -> state.detail.album.title
else -> "Album"
}
@Composable
private fun AlbumBody(
detail: AlbumDetailRef,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
onTrackClick: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) {
item { AlbumHeader(detail.album, onPlayAll, onShuffleAll) }
item { HorizontalDivider() }
items(items = detail.tracks, key = { it.id }) { track ->
TrackRow(track = track, onClick = { onTrackClick(track.id) })
HorizontalDivider()
}
if (detail.tracks.isEmpty()) {
item {
Text(
text = "No tracks on this album.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(24.dp),
)
}
}
}
}
@Composable
private fun AlbumHeader(
album: AlbumRef,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AlbumCover(album)
Column(modifier = Modifier.weight(1f)) {
Text(
text = album.title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (album.artistName.isNotEmpty()) {
Text(
text = album.artistName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(4.dp))
Text(
text = trackCountLine(album),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onPlayAll,
colors = ButtonDefaults.buttonColors(),
) {
Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Play")
}
OutlinedButton(onClick = onShuffleAll) {
Icon(Lucide.Shuffle, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Shuffle")
}
}
}
}
@Composable
private fun AlbumCover(album: AlbumRef) {
Box(
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (album.coverUrl.isEmpty()) {
Icon(
imageVector = Lucide.Disc3,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp),
)
} else {
AsyncImage(
model = album.coverUrl,
contentDescription = album.title,
modifier = Modifier.fillMaxSize(),
)
}
}
}
private fun trackCountLine(album: AlbumRef): String {
val tracks = if (album.trackCount == 1) "1 track" else "${album.trackCount} tracks"
return if (album.year != null) "$tracks · ${album.year}" else tracks
}
@Composable
private fun TrackRow(track: TrackRef, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${track.trackNumber ?: 0}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(28.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (track.artistName.isNotEmpty()) {
Text(
text = track.artistName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = formatDuration(track.durationSec),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
private const val SECONDS_PER_MINUTE = 60
private fun formatDuration(seconds: Int): String {
if (seconds <= 0) return "--:--"
val m = seconds / SECONDS_PER_MINUTE
val s = seconds % SECONDS_PER_MINUTE
return "$m:${s.toString().padStart(2, '0')}"
}
@@ -0,0 +1,68 @@
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.AlbumDetailRef
import com.fabledsword.minstrel.nav.AlbumDetail
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 AlbumDetailUiState {
data object Loading : AlbumDetailUiState
data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState
data class Error(val message: String) : AlbumDetailUiState
}
@HiltViewModel
class AlbumDetailViewModel @Inject constructor(
private val repository: LibraryRepository,
private val player: PlayerController,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val route: AlbumDetail = savedStateHandle.toRoute()
val albumId: String = route.id
private val internal = MutableStateFlow<AlbumDetailUiState>(AlbumDetailUiState.Loading)
val uiState: StateFlow<AlbumDetailUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.value = AlbumDetailUiState.Loading
try {
val detail = repository.refreshAlbumDetail(albumId)
internal.value = AlbumDetailUiState.Success(detail)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AlbumDetailUiState.Error(e.message ?: "Album load failed")
}
}
}
/** Play the album starting at [startTrackId] (or the first track when null). */
fun play(startTrackId: String?) {
val detail = (internal.value as? AlbumDetailUiState.Success)?.detail ?: return
if (detail.tracks.isEmpty()) return
val startIndex = detail.tracks
.indexOfFirst { it.id == startTrackId }
.coerceAtLeast(0)
player.setQueue(
tracks = detail.tracks,
initialIndex = startIndex,
source = "album:${detail.album.id}",
)
}
}
@@ -0,0 +1,12 @@
package com.fabledsword.minstrel.models
/**
* Snapshot of one album with its track list. Returned by
* `LibraryRepository.refreshAlbumDetail` so the AlbumDetail screen
* can render immediately from the server response without waiting
* for the Room round-trip.
*/
data class AlbumDetailRef(
val album: AlbumRef,
val tracks: List<TrackRef>,
)
@@ -18,6 +18,7 @@ import com.fabledsword.minstrel.auth.ui.LoginScreen
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.LibraryScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
@@ -103,10 +104,9 @@ private fun NavGraphBuilder.inShellDetail(
navController: NavHostController,
expandPlayer: () -> Unit,
) {
composable<AlbumDetail> { backStackEntry ->
val route: AlbumDetail = backStackEntry.toRoute()
composable<AlbumDetail> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Album", "Album detail for ${route.id} — later 5.x slice.")
AlbumDetailScreen(navController = navController)
}
}
composable<ArtistDetail> { backStackEntry ->