feat(subsonic): auth + envelope + ping.view + getLicense.view (Fable #294) #7
@@ -1,13 +1,11 @@
|
|||||||
package com.fabledsword.minstrel.home.ui
|
package com.fabledsword.minstrel.home.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.library.widgets.ArtistCard
|
||||||
import com.fabledsword.minstrel.models.AlbumRef
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Home
|
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.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
@@ -63,6 +65,7 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
|||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class HomeSections(
|
data class HomeSections(
|
||||||
|
val playlists: List<PlaylistRef> = emptyList(),
|
||||||
val recentlyAddedAlbums: List<AlbumRef> = emptyList(),
|
val recentlyAddedAlbums: List<AlbumRef> = emptyList(),
|
||||||
val rediscoverAlbums: List<AlbumRef> = emptyList(),
|
val rediscoverAlbums: List<AlbumRef> = emptyList(),
|
||||||
val rediscoverArtists: List<ArtistRef> = emptyList(),
|
val rediscoverArtists: List<ArtistRef> = emptyList(),
|
||||||
@@ -70,7 +73,8 @@ data class HomeSections(
|
|||||||
val lastPlayedArtists: List<ArtistRef> = emptyList(),
|
val lastPlayedArtists: List<ArtistRef> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val isAllEmpty: Boolean
|
val isAllEmpty: Boolean
|
||||||
get() = recentlyAddedAlbums.isEmpty() &&
|
get() = playlists.isEmpty() &&
|
||||||
|
recentlyAddedAlbums.isEmpty() &&
|
||||||
rediscoverAlbums.isEmpty() &&
|
rediscoverAlbums.isEmpty() &&
|
||||||
rediscoverArtists.isEmpty() &&
|
rediscoverArtists.isEmpty() &&
|
||||||
mostPlayedTracks.isEmpty() &&
|
mostPlayedTracks.isEmpty() &&
|
||||||
@@ -88,7 +92,8 @@ sealed interface HomeUiState {
|
|||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeViewModel @Inject constructor(
|
class HomeViewModel @Inject constructor(
|
||||||
private val repository: HomeRepository,
|
private val homeRepository: HomeRepository,
|
||||||
|
private val playlistsRepository: PlaylistsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -96,34 +101,47 @@ class HomeViewModel @Inject constructor(
|
|||||||
// immediately and refreshes as the IDs land. Errors map to the
|
// immediately and refreshes as the IDs land. Errors map to the
|
||||||
// Error state via the combine() chain's catch below.
|
// Error state via the combine() chain's catch below.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.refreshIndex() }
|
runCatching { homeRepository.refreshIndex() }
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { playlistsRepository.refreshList() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<HomeUiState> =
|
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
|
||||||
combine(
|
scope = viewModelScope,
|
||||||
repository.observeRecentlyAddedAlbums(),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
repository.observeRediscoverAlbums(),
|
initialValue = HomeUiState.Loading,
|
||||||
repository.observeRediscoverArtists(),
|
)
|
||||||
repository.observeMostPlayedTracks(),
|
|
||||||
repository.observeLastPlayedArtists(),
|
/**
|
||||||
) { recentAlbums, rediscoverAlbums, rediscoverArtists, mostPlayedTracks, lastPlayedArtists ->
|
* Five home-section flows merged into the sections struct without
|
||||||
val sections = HomeSections(
|
* playlists; then merged with the playlists flow in [combineHomeFlows].
|
||||||
recentlyAddedAlbums = recentAlbums,
|
* Splitting it like this keeps each combine call inside the
|
||||||
rediscoverAlbums = rediscoverAlbums,
|
* coroutines built-in arity limit (5) and avoids the typed-vararg
|
||||||
rediscoverArtists = rediscoverArtists,
|
* acrobatics needed for a 6-flow combine across heterogeneous types.
|
||||||
mostPlayedTracks = mostPlayedTracks,
|
*/
|
||||||
lastPlayedArtists = lastPlayedArtists,
|
private fun observeHomeSections() = combine(
|
||||||
)
|
homeRepository.observeRecentlyAddedAlbums(),
|
||||||
if (sections.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(sections)
|
homeRepository.observeRediscoverAlbums(),
|
||||||
}
|
homeRepository.observeRediscoverArtists(),
|
||||||
.catch { e -> emit(HomeUiState.Error(e.message ?: "Home load failed")) }
|
homeRepository.observeMostPlayedTracks(),
|
||||||
.stateIn(
|
homeRepository.observeLastPlayedArtists(),
|
||||||
scope = viewModelScope,
|
) { recent, rediscoverAlbums, rediscoverArtists, mostPlayed, lastPlayed ->
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
HomeSections(
|
||||||
initialValue = HomeUiState.Loading,
|
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 ──────────────────────────────────────────────────────────
|
// ─── Screen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,6 +182,7 @@ fun HomeScreen(
|
|||||||
sections = s.sections,
|
sections = s.sections,
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,18 +201,14 @@ private fun HomeSuccessContent(
|
|||||||
sections: HomeSections,
|
sections: HomeSections,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
onArtistClick: (String) -> Unit,
|
onArtistClick: (String) -> Unit,
|
||||||
|
onPlaylistClick: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
// Playlists section is a Phase-7 deliverable; placeholder header
|
if (sections.playlists.isNotEmpty()) {
|
||||||
// keeps the row visible so users see what's coming.
|
item { PlaylistsRow(sections.playlists, onPlaylistClick) }
|
||||||
item {
|
|
||||||
EmptySectionHeader(
|
|
||||||
title = "Playlists",
|
|
||||||
body = "System playlists land with Phase 7.",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (sections.recentlyAddedAlbums.isNotEmpty()) {
|
if (sections.recentlyAddedAlbums.isNotEmpty()) {
|
||||||
item {
|
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
|
@Composable
|
||||||
private fun ArtistsRow(
|
private fun ArtistsRow(
|
||||||
title: String,
|
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.home.ui.HomeScreen
|
||||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
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.playlists.ui.PlaylistsListScreen
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
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.")
|
ComingSoon("Artist", "Artist detail for ${route.id} — later 5.x slice.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<PlaylistDetail> { backStackEntry ->
|
composable<PlaylistDetail> {
|
||||||
val route: PlaylistDetail = backStackEntry.toRoute()
|
|
||||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
ComingSoon("Playlist", "Playlist detail for ${route.id} — Phase 7.")
|
PlaylistDetailScreen(navController = navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<AdminRequests> {
|
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