feat(android): Phase 7 — playlist detail screen + Home playlists row

Second slice of Playlists feature parity. PlaylistDetail route now
renders the real screen instead of the ComingSoon stub; Home's
Playlists row now shows actual PlaylistCards instead of the
placeholder header.

New:
  - playlists/ui/PlaylistDetailScreen.kt — VM + UiState + Screen.
    Header (cover + name + description + track count + Play / Shuffle
    buttons) over a track list (numbered TrackRow with duration).
    Unavailable tracks (trackId == null because the upstream library
    row was removed) render at 0.4 alpha and don't accept taps, per
    Flutter's `isAvailable` convention. Tap on a track plays the
    playlist starting there via `PlayerController.setQueue(refs,
    initialIndex, source = "playlist:$id")`. Shuffle reuses the same
    path on a `shuffled()` copy — cheap for the page-sized list.

Modified:
  - home/ui/HomeScreen.kt — HomeViewModel now also takes
    PlaylistsRepository; calls refreshList() alongside refreshIndex()
    on init. HomeSections gets a `playlists: List<PlaylistRef>` field
    (factored into isAllEmpty). HomeSuccessContent shows a real
    PlaylistsRow when non-empty (replacing the "Lands in Phase 7"
    EmptySectionHeader). Tile tap navigates to PlaylistDetail.
    Combine arity capped at 5 by kotlinx.coroutines, so the screen
    splits into observeHomeSections() (the five HomeRepository flows)
    chained against the PlaylistsRepository flow — avoids untyped
    vararg-combine gymnastics across heterogeneous list types.
  - nav/MinstrelNavGraph.kt — PlaylistDetail route swaps from
    ComingSoon to `PlaylistDetailScreen(navController = navController)`.
    The route id flows in via SavedStateHandle.toRoute() inside the
    ViewModel rather than backStackEntry.toRoute(), so the composable
    block is back to a one-liner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 21:51:53 -04:00
parent b855009ea0
commit f6057747ba
3 changed files with 452 additions and 65 deletions
@@ -1,13 +1,11 @@
package com.fabledsword.minstrel.home.ui
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.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
@@ -41,10 +39,14 @@ import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@@ -63,6 +65,7 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
// ─── State ───────────────────────────────────────────────────────────
data class HomeSections(
val playlists: List<PlaylistRef> = emptyList(),
val recentlyAddedAlbums: List<AlbumRef> = emptyList(),
val rediscoverAlbums: List<AlbumRef> = emptyList(),
val rediscoverArtists: List<ArtistRef> = emptyList(),
@@ -70,7 +73,8 @@ data class HomeSections(
val lastPlayedArtists: List<ArtistRef> = emptyList(),
) {
val isAllEmpty: Boolean
get() = recentlyAddedAlbums.isEmpty() &&
get() = playlists.isEmpty() &&
recentlyAddedAlbums.isEmpty() &&
rediscoverAlbums.isEmpty() &&
rediscoverArtists.isEmpty() &&
mostPlayedTracks.isEmpty() &&
@@ -88,7 +92,8 @@ sealed interface HomeUiState {
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: HomeRepository,
private val homeRepository: HomeRepository,
private val playlistsRepository: PlaylistsRepository,
) : ViewModel() {
init {
@@ -96,34 +101,47 @@ class HomeViewModel @Inject constructor(
// immediately and refreshes as the IDs land. Errors map to the
// Error state via the combine() chain's catch below.
viewModelScope.launch {
runCatching { repository.refreshIndex() }
runCatching { homeRepository.refreshIndex() }
}
viewModelScope.launch {
runCatching { playlistsRepository.refreshList() }
}
}
val uiState: StateFlow<HomeUiState> =
combine(
repository.observeRecentlyAddedAlbums(),
repository.observeRediscoverAlbums(),
repository.observeRediscoverArtists(),
repository.observeMostPlayedTracks(),
repository.observeLastPlayedArtists(),
) { recentAlbums, rediscoverAlbums, rediscoverArtists, mostPlayedTracks, lastPlayedArtists ->
val sections = HomeSections(
recentlyAddedAlbums = recentAlbums,
rediscoverAlbums = rediscoverAlbums,
rediscoverArtists = rediscoverArtists,
mostPlayedTracks = mostPlayedTracks,
lastPlayedArtists = lastPlayedArtists,
)
if (sections.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(sections)
}
.catch { e -> emit(HomeUiState.Error(e.message ?: "Home load failed")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = HomeUiState.Loading,
)
}
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = HomeUiState.Loading,
)
/**
* Five home-section flows merged into the sections struct without
* playlists; then merged with the playlists flow in [combineHomeFlows].
* Splitting it like this keeps each combine call inside the
* coroutines built-in arity limit (5) and avoids the typed-vararg
* acrobatics needed for a 6-flow combine across heterogeneous types.
*/
private fun observeHomeSections() = combine(
homeRepository.observeRecentlyAddedAlbums(),
homeRepository.observeRediscoverAlbums(),
homeRepository.observeRediscoverArtists(),
homeRepository.observeMostPlayedTracks(),
homeRepository.observeLastPlayedArtists(),
) { recent, rediscoverAlbums, rediscoverArtists, mostPlayed, lastPlayed ->
HomeSections(
recentlyAddedAlbums = recent,
rediscoverAlbums = rediscoverAlbums,
rediscoverArtists = rediscoverArtists,
mostPlayedTracks = mostPlayed,
lastPlayedArtists = lastPlayed,
)
}
private fun combineHomeFlows() =
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
val merged = sections.copy(playlists = playlists)
if (merged.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(merged)
}.catch { e -> emit(HomeUiState.Error(e.message ?: "Home load failed")) }
// ─── Screen ──────────────────────────────────────────────────────────
@@ -164,6 +182,7 @@ fun HomeScreen(
sections = s.sections,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
)
}
}
@@ -182,18 +201,14 @@ private fun HomeSuccessContent(
sections: HomeSections,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
// Playlists section is a Phase-7 deliverable; placeholder header
// keeps the row visible so users see what's coming.
item {
EmptySectionHeader(
title = "Playlists",
body = "System playlists land with Phase 7.",
)
if (sections.playlists.isNotEmpty()) {
item { PlaylistsRow(sections.playlists, onPlaylistClick) }
}
if (sections.recentlyAddedAlbums.isNotEmpty()) {
item {
@@ -256,6 +271,18 @@ private fun AlbumsRow(
}
}
@Composable
private fun PlaylistsRow(
playlists: List<PlaylistRef>,
onPlaylistClick: (String) -> Unit,
) {
HorizontalScrollRow(title = "Playlists") {
items(items = playlists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
}
}
}
@Composable
private fun ArtistsRow(
title: String,
@@ -331,29 +358,3 @@ private fun CompactTrackTile(
}
}
/**
* Placeholder header used for sections whose data layer hasn't landed yet
* (currently just Playlists). Smaller than EmptyState — the surrounding
* sections still have content, so this is more of a soft "TBD" marker.
*/
@Composable
private fun EmptySectionHeader(title: String, body: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -13,6 +13,7 @@ import androidx.navigation.toRoute
import com.fabledsword.minstrel.home.ui.HomeScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
@@ -101,10 +102,9 @@ private fun NavGraphBuilder.inShellDetail(expandPlayer: () -> Unit) {
ComingSoon("Artist", "Artist detail for ${route.id} — later 5.x slice.")
}
}
composable<PlaylistDetail> { backStackEntry ->
val route: PlaylistDetail = backStackEntry.toRoute()
composable<PlaylistDetail> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Playlist", "Playlist detail for ${route.id} — Phase 7.")
PlaylistDetailScreen(navController = navController)
}
}
composable<AdminRequests> {
@@ -0,0 +1,386 @@
package com.fabledsword.minstrel.playlists.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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import androidx.navigation.toRoute
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
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
// ─── State ───────────────────────────────────────────────────────────
sealed interface PlaylistDetailUiState {
data object Loading : PlaylistDetailUiState
data class Success(val detail: PlaylistDetailRef) : PlaylistDetailUiState
data class Error(val message: String) : PlaylistDetailUiState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
class PlaylistDetailViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val player: PlayerController,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val route: PlaylistDetail = savedStateHandle.toRoute()
val playlistId: String = route.id
private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading)
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
try {
internal.value = PlaylistDetailUiState.Loading
val detail = repository.refreshDetail(playlistId)
internal.value = PlaylistDetailUiState.Success(detail)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = PlaylistDetailUiState.Error(
e.message ?: "Couldn't load playlist",
)
}
}
}
/** Play the available tracks starting at [startTrackId] (or first available). */
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
val playable = tracks.filter { it.isAvailable && !it.streamUrl.isNullOrEmpty() }
if (playable.isEmpty()) return
val refs = playable.map { it.toTrackRef() }
val startIndex = refs.indexOfFirst { it.id == startTrackId }.coerceAtLeast(0)
player.setQueue(refs, initialIndex = startIndex, source = "playlist:$playlistId")
}
}
// ─── Screen ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistDetailScreen(
navController: NavHostController,
viewModel: PlaylistDetailViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(currentTitle(state)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) {
PlaylistDetailUiState.Loading -> LoadingCentered()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
is PlaylistDetailUiState.Success -> PlaylistDetailBody(
detail = s.detail,
onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
onShuffleAll = {
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
},
onTrackClick = { id -> viewModel.play(s.detail.tracks, startTrackId = id) },
)
}
}
}
}
private fun currentTitle(state: PlaylistDetailUiState): String = when (state) {
is PlaylistDetailUiState.Success -> state.detail.playlist.name
else -> "Playlist"
}
// ─── Body composables ────────────────────────────────────────────────
@Composable
private fun PlaylistDetailBody(
detail: PlaylistDetailRef,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
onTrackClick: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) {
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll) }
item { HorizontalDivider() }
items(items = detail.tracks, key = { it.position }) { row ->
TrackRow(row = row, onClick = { row.trackId?.let(onTrackClick) })
HorizontalDivider()
}
if (detail.tracks.isEmpty()) {
item {
Text(
text = "No tracks in this playlist yet.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(24.dp),
)
}
}
}
}
@Composable
private fun PlaylistHeader(
playlist: PlaylistRef,
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,
) {
PlaylistCover(playlist)
Column(modifier = Modifier.weight(1f)) {
Text(
text = playlist.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (playlist.description.isNotEmpty()) {
Text(
text = playlist.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(4.dp))
Text(
text = "${playlist.trackCount} tracks",
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 PlaylistCover(playlist: PlaylistRef) {
Box(
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (playlist.coverUrl.isEmpty()) {
Icon(
imageVector = Lucide.ListMusic,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp),
)
} else {
AsyncImage(
model = playlist.coverUrl,
contentDescription = playlist.name,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
private fun TrackRow(row: PlaylistTrackRef, onClick: () -> Unit) {
val enabled = row.isAvailable
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${row.position}",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.width(28.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = rowAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = row.artistName.ifEmpty { row.albumTitle },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = formatDuration(row.durationSec),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha),
modifier = Modifier.padding(start = 12.dp),
)
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@Composable
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
var retried by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = {
if (!retried) {
retried = true
onRetry()
}
},
) { Text("Retry") }
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
private const val SECONDS_PER_MINUTE = 60
private const val UNAVAILABLE_ALPHA = 0.4f
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')}"
}
/**
* Lossy mapping: PlaylistTrackRef → TrackRef so the player can take it.
* Caller must filter `isAvailable && streamUrl != null` first.
*/
private fun PlaylistTrackRef.toTrackRef(): TrackRef = TrackRef(
id = trackId.orEmpty(),
title = title,
albumId = albumId.orEmpty(),
artistId = artistId.orEmpty(),
albumTitle = albumTitle,
artistName = artistName,
durationSec = durationSec,
streamUrl = streamUrl.orEmpty(),
)