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:
@@ -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> {
|
||||
|
||||
+386
@@ -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(),
|
||||
)
|
||||
Reference in New Issue
Block a user