Release: web UX overhaul + Android native port + server polish #59

Merged
bvandeusen merged 298 commits from dev into main 2026-06-01 16:11:21 -04:00
3 changed files with 452 additions and 65 deletions
Showing only changes of commit f6057747ba - Show all commits
@@ -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> {
@@ -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(),
)