From 72f36c0da81ebf4c68979d5b4817ff9dab14cfd0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 12:54:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2012=20=E2=80=94=20Album?= =?UTF-8?q?Detail=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../library/data/LibraryRepository.kt | 24 +- .../minstrel/library/ui/AlbumDetailScreen.kt | 274 ++++++++++++++++++ .../library/ui/AlbumDetailViewModel.kt | 68 +++++ .../minstrel/models/AlbumDetailRef.kt | 12 + .../minstrel/nav/MinstrelNavGraph.kt | 6 +- 5 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/AlbumDetailRef.kt 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 5a64dd0b..63f2c5b6 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 @@ -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. */ diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt new file mode 100644 index 00000000..a967f31c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -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')}" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt new file mode 100644 index 00000000..1713e32f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt @@ -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.Loading) + val uiState: StateFlow = 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}", + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumDetailRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumDetailRef.kt new file mode 100644 index 00000000..3d84cca0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumDetailRef.kt @@ -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, +) 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 44e866d0..edb5db37 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 @@ -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 { backStackEntry -> - val route: AlbumDetail = backStackEntry.toRoute() + composable { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Album", "Album detail for ${route.id} — later 5.x slice.") + AlbumDetailScreen(navController = navController) } } composable { backStackEntry ->