diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index 459fb868..d1a0552f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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 = emptyList(), val recentlyAddedAlbums: List = emptyList(), val rediscoverAlbums: List = emptyList(), val rediscoverArtists: List = emptyList(), @@ -70,7 +73,8 @@ data class HomeSections( val lastPlayedArtists: List = 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 = - 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 = 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, + 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, - ) - } -} - 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 9bbf2fc4..25b57f8a 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 @@ -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 { backStackEntry -> - val route: PlaylistDetail = backStackEntry.toRoute() + composable { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Playlist", "Playlist detail for ${route.id} — Phase 7.") + PlaylistDetailScreen(navController = navController) } } composable { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt new file mode 100644 index 00000000..ad22befc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -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.Loading) + val uiState: StateFlow = 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, 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(), +)