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:
+22
-2
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user