feat(android): Phase 7 — playlists list (data layer + screen + nav)

Foundation slice of Playlists feature parity with Flutter v2026.05.21.0.
Replaces the ComingSoon stub on the Playlists route with a real
two-section list screen (System playlists / Your playlists), cache-first
against `cached_playlists`.

New:
  - models/Playlist.kt — PlaylistRef + PlaylistTrackRef domain models.
    Mirrors `flutter_client/lib/models/playlist.dart` (id + name +
    isSystem + cover + ownerUsername etc.; PlaylistTrack carries the
    full per-row display fields since cached_playlist_tracks only holds
    ordered (playlistId, trackId, position) triples).
  - models/wire/PlaylistWire.kt — @Serializable wire types
    (PlaylistWire / PlaylistsListWire / PlaylistTrackWire /
    PlaylistDetailWire), matching `internal/api/playlists.go`.
  - api/endpoints/PlaylistsApi.kt — Retrofit interface (list + get).
    Read-only for now; create/append/refreshSystem land with the
    mutation-queue phase.
  - playlists/data/PlaylistsRepository.kt — observe (all / user /
    system) cache-first reads + refreshList / refreshDetail that
    upsert Room and return a hydrated PlaylistDetailRef. Internal
    entity↔wire↔domain mappers kept private.
  - playlists/widgets/PlaylistCard.kt — 176dp tile sized to match
    AlbumCard for visual consistency in the upcoming Home carousel.
    Subtitle shows the system-variant label ("For You" / "Discover" /
    "Songs like…" / etc.) or "N tracks" for user playlists.
  - playlists/ui/PlaylistsListScreen.kt — Scaffold + TopAppBar +
    MainAppBarActions; LazyVerticalGrid with adaptive 176dp cells.
    Renders both `System playlists` and `Your playlists` sections via
    full-width section headers (`GridItemSpan(maxLineSpan)`).

Modified:
  - cache/db/DatabaseModule.kt — adds @Provides for CachedPlaylistDao
    + CachedPlaylistTrackDao (DAOs existed on AppDatabase since slice 1
    but had no consumer until now).
  - nav/MinstrelNavGraph.kt — swaps the ComingSoon stub for
    `PlaylistsListScreen(navController = navController)`.

PlaylistDetail route still ComingSoon; lands in the next commit along
with Home-page Playlists row integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 21:26:26 -04:00
parent df9ae2db77
commit b855009ea0
8 changed files with 629 additions and 1 deletions
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistsListWire
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for `/api/playlists`. Mirrors
* `flutter_client/lib/api/endpoints/playlists.dart`.
*
* Read-only for now — playlist creation, track-append, and the system
* shuffle/refresh endpoints come in with the mutation-queue phase.
*/
interface PlaylistsApi {
/**
* `kind` = "user" | "system" | "all". Server returns
* `{"owned": [...], "public": [...]}` — `owned` is the caller's
* playlists filtered by kind, `public` is other users' shared
* playlists (not kind-filtered).
*/
@GET("api/playlists")
suspend fun list(@Query("kind") kind: String = "all"): PlaylistsListWire
@GET("api/playlists/{id}")
suspend fun get(@Path("id") id: String): PlaylistDetailWire
}
@@ -6,6 +6,8 @@ import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import dagger.Module
@@ -58,5 +60,15 @@ object DatabaseModule {
fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao =
db.cachedHomeIndexDao()
@Provides
@Singleton
fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao =
db.cachedPlaylistDao()
@Provides
@Singleton
fun provideCachedPlaylistTrackDao(db: AppDatabase): CachedPlaylistTrackDao =
db.cachedPlaylistTrackDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -0,0 +1,47 @@
package com.fabledsword.minstrel.models
/**
* Lightweight reference to one playlist (user or system-generated).
* Mirrors `flutter_client/lib/models/playlist.dart`'s `Playlist`.
*
* `systemVariant` discriminates user vs. system playlists — null for
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc.
* for server-generated mixes. The UI gates "edit / delete / append
* tracks" affordances on `isSystem` so user playlists are the only
* mutable ones.
*/
data class PlaylistRef(
val id: String,
val userId: String,
val name: String,
val description: String = "",
val isPublic: Boolean = false,
val systemVariant: String? = null,
val trackCount: Int = 0,
val coverUrl: String = "",
val ownerUsername: String = "",
) {
val isSystem: Boolean get() = systemVariant != null
}
/**
* One row of a playlist's tracks. Mirrors Flutter's `PlaylistTrack`.
*
* `trackId` and `streamUrl` are nullable because the upstream track can
* be removed from the library while the row stays in the playlist —
* those tiles render grey + unplayable per Flutter's `isAvailable`
* convention.
*/
data class PlaylistTrackRef(
val position: Int,
val trackId: String?,
val title: String,
val albumId: String?,
val albumTitle: String = "",
val artistId: String?,
val artistName: String = "",
val durationSec: Int = 0,
val streamUrl: String? = null,
) {
val isAvailable: Boolean get() = trackId != null
}
@@ -0,0 +1,76 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape of one playlist as returned by `/api/playlists` and
* `/api/playlists/{id}`. Mirrors `internal/api/playlists.go Playlist`.
*
* All string fields default to empty so missing keys don't throw.
* `systemVariant` is nullable — non-null means a system-generated mix.
*/
@Serializable
data class PlaylistWire(
val id: String,
@SerialName("user_id") val userId: String = "",
val name: String = "",
val description: String = "",
@SerialName("is_public") val isPublic: Boolean = false,
@SerialName("system_variant") val systemVariant: String? = null,
@SerialName("track_count") val trackCount: Int = 0,
@SerialName("cover_url") val coverUrl: String = "",
@SerialName("owner_username") val ownerUsername: String = "",
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
)
/**
* `GET /api/playlists?kind=user|system|all` envelope. Server splits
* the caller's own playlists from public ones owned by others; both
* are surfaced to the UI as separate sections.
*/
@Serializable
data class PlaylistsListWire(
val owned: List<PlaylistWire> = emptyList(),
val public: List<PlaylistWire> = emptyList(),
)
/**
* One row inside a `PlaylistDetailWire.tracks`. `trackId` / `albumId`
* / `artistId` / `streamUrl` are nullable because the upstream track
* may have been removed from the library while the row stays in the
* playlist with its display fields preserved.
*/
@Serializable
data class PlaylistTrackWire(
val position: Int = 0,
@SerialName("track_id") val trackId: String? = null,
val title: String = "",
@SerialName("album_id") val albumId: String? = null,
@SerialName("album_title") val albumTitle: String = "",
@SerialName("artist_id") val artistId: String? = null,
@SerialName("artist_name") val artistName: String = "",
@SerialName("duration_sec") val durationSec: Int = 0,
@SerialName("stream_url") val streamUrl: String? = null,
)
/**
* `GET /api/playlists/{id}` envelope. Server flattens the playlist
* fields at the root level and adds a `tracks` array — matches the
* Flutter fromJson which calls `Playlist.fromJson(j)` on the same map
* that owns `tracks`.
*/
@Serializable
data class PlaylistDetailWire(
val id: String,
@SerialName("user_id") val userId: String = "",
val name: String = "",
val description: String = "",
@SerialName("is_public") val isPublic: Boolean = false,
@SerialName("system_variant") val systemVariant: String? = null,
@SerialName("track_count") val trackCount: Int = 0,
@SerialName("cover_url") val coverUrl: String = "",
@SerialName("owner_username") val ownerUsername: String = "",
val tracks: List<PlaylistTrackWire> = emptyList(),
)
@@ -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.PlaylistsListScreen
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
@@ -67,7 +68,7 @@ private fun NavGraphBuilder.inShellTopLevel(
}
composable<Playlists> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Playlists", "Lands in Phase 7.")
PlaylistsListScreen(navController = navController)
}
}
composable<Settings> {
@@ -0,0 +1,173 @@
package com.fabledsword.minstrel.playlists.data
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Cache-first reads of the caller's playlists plus per-playlist track
* lists. Mirrors the Flutter `playlistsListProvider` + `playlistDetail`
* pattern: Room is the source of truth for UI observation; refresh*
* methods push wire data into Room and the Flow emissions propagate.
*
* Track-list hydration: `cached_playlist_tracks` only stores ordered
* (playlistId, trackId, position) triples. The full per-track display
* fields come back from `/api/playlists/{id}` in `PlaylistTrackWire`
* — we cache the membership rows in Room (so the playlist tracks list
* persists offline at the ID level) AND keep the freshest detail
* response in memory via the returned `PlaylistDetailRef`. A proper
* per-track hydration queue lands with the Phase 12 sync controller.
*/
@Singleton
class PlaylistsRepository @Inject constructor(
private val playlistDao: CachedPlaylistDao,
private val playlistTrackDao: CachedPlaylistTrackDao,
retrofit: Retrofit,
) {
private val api: PlaylistsApi = retrofit.create()
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
/** Owned + public; UI splits by isSystem / userId comparison. */
fun observeAll(): Flow<List<PlaylistRef>> =
playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
/** User-owned playlists only (systemVariant IS NULL). */
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
playlistDao.observeUserPlaylists().map { rows -> rows.map { it.toDomain() } }
/** Server-generated system playlists (for_you / discover / etc.). */
fun observeSystemPlaylists(): Flow<List<PlaylistRef>> =
playlistDao.observeSystemPlaylists().map { rows -> rows.map { it.toDomain() } }
suspend fun getPlaylist(id: String): PlaylistRef? = playlistDao.getById(id)?.toDomain()
// ── Server refreshes ──────────────────────────────────────────────
/**
* Pulls `GET /api/playlists?kind=all` and replaces the local
* playlist cache. Per-playlist track rows come from refreshDetail.
*/
suspend fun refreshList() {
val wire = api.list(kind = "all")
val all = wire.owned + wire.public
playlistDao.upsertAll(all.map { it.toEntity() })
}
/**
* Pulls `GET /api/playlists/{id}` and persists both the playlist
* row and its ordered track membership. Returns the freshly-
* hydrated detail (with full per-track display fields) for the
* detail screen to render immediately — Room only persists the
* track-membership IDs, not the display fields.
*/
suspend fun refreshDetail(id: String): PlaylistDetailRef {
val wire = api.get(id)
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
playlistTrackDao.deleteByPlaylist(id)
playlistTrackDao.upsertAll(
wire.tracks.mapNotNull { row ->
row.trackId?.let { trackId ->
CachedPlaylistTrackEntity(
playlistId = id,
trackId = trackId,
position = row.position,
)
}
},
)
return PlaylistDetailRef(
playlist = wire.toPlaylistRef(),
tracks = wire.tracks.map { it.toDomain() },
)
}
}
/**
* Snapshot of a playlist + its per-track display fields, as returned
* by `PlaylistsRepository.refreshDetail`. UI uses this directly for
* the initial render; offline fall-back reads playlist membership IDs
* from Room and renders them as greyed-out placeholders until the
* sync controller hydrates per-track details.
*/
data class PlaylistDetailRef(
val playlist: PlaylistRef,
val tracks: List<PlaylistTrackRef>,
)
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
PlaylistRef(
id = id,
userId = userId,
name = name,
description = description,
isPublic = isPublic,
systemVariant = systemVariant,
trackCount = trackCount,
coverUrl = coverPath ?: "",
)
private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
CachedPlaylistEntity(
id = id,
userId = userId,
name = name,
description = description,
isPublic = isPublic,
coverPath = coverUrl.ifEmpty { null },
trackCount = trackCount,
systemVariant = systemVariant,
)
private fun PlaylistDetailWire.toPlaylistEntity(): CachedPlaylistEntity =
CachedPlaylistEntity(
id = id,
userId = userId,
name = name,
description = description,
isPublic = isPublic,
coverPath = coverUrl.ifEmpty { null },
trackCount = trackCount,
systemVariant = systemVariant,
)
private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
PlaylistRef(
id = id,
userId = userId,
name = name,
description = description,
isPublic = isPublic,
systemVariant = systemVariant,
trackCount = trackCount,
coverUrl = coverUrl,
ownerUsername = ownerUsername,
)
private fun PlaylistTrackWire.toDomain(): PlaylistTrackRef =
PlaylistTrackRef(
position = position,
trackId = trackId,
title = title,
albumId = albumId,
albumTitle = albumTitle,
artistId = artistId,
artistName = artistName,
durationSec = durationSec,
streamUrl = streamUrl,
)
@@ -0,0 +1,184 @@
package com.fabledsword.minstrel.playlists.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists
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.MainAppBarActions
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ───────────────────────────────────────────────────────────
sealed interface PlaylistsListUiState {
data object Loading : PlaylistsListUiState
data object Empty : PlaylistsListUiState
data class Success(val playlists: List<PlaylistRef>) : PlaylistsListUiState
data class Error(val message: String) : PlaylistsListUiState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository,
) : ViewModel() {
init {
// Fire-and-forget initial pull; the screen renders cached rows
// immediately and refreshes as the server response lands.
viewModelScope.launch {
runCatching { repository.refreshList() }
}
}
val uiState: StateFlow<PlaylistsListUiState> =
repository.observeAll()
.map { list ->
if (list.isEmpty()) {
PlaylistsListUiState.Empty
} else {
PlaylistsListUiState.Success(list)
}
}
.catch { e ->
emit(PlaylistsListUiState.Error(e.message ?: "Playlists load failed"))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = PlaylistsListUiState.Loading,
)
}
// ─── Screen ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistsListScreen(
navController: NavHostController,
viewModel: PlaylistsListViewModel = hiltViewModel(),
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Playlists") },
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Playlists::class.qualifiedName,
)
},
)
},
) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) {
PlaylistsListUiState.Loading -> LoadingCentered()
PlaylistsListUiState.Empty -> EmptyState(
title = "No playlists yet",
body = "System playlists (For You / Discover / Songs like…) " +
"appear once your library has enough plays. Create your " +
"own playlist from any album or track in the meantime.",
)
is PlaylistsListUiState.Error -> EmptyState(
title = "Couldn't load playlists",
body = s.message,
)
is PlaylistsListUiState.Success -> PlaylistsGrid(
playlists = s.playlists,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
)
}
}
}
}
@Composable
private fun PlaylistsGrid(
playlists: List<PlaylistRef>,
onPlaylistClick: (String) -> Unit,
) {
val systemPlaylists = playlists.filter { it.isSystem }
val userPlaylists = playlists.filter { !it.isSystem }
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 176.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (systemPlaylists.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader("System playlists")
}
items(items = systemPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
}
}
if (userPlaylists.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader("Your playlists")
}
items(items = userPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
)
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,107 @@
package com.fabledsword.minstrel.playlists.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
* One playlist tile. Sized to match `AlbumCard` (176dp wide, 144dp
* square cover) so they line up in the Home Playlists carousel.
* Mirrors `flutter_client/lib/playlists/widgets/playlist_card.dart`.
*
* System playlists carry a small "For You" / "Discover" / etc. label
* subtitle under the name (substituting for the artist-name line on
* AlbumCard).
*/
@Composable
fun PlaylistCard(
playlist: PlaylistRef,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.width(176.dp)
.clickable(onClick = onClick),
color = Color.Transparent,
) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Box(
modifier = Modifier
.size(144.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)),
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(),
contentScale = ContentScale.Crop,
)
}
}
Spacer(Modifier.height(8.dp))
Text(
text = playlist.name,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitleFor(playlist),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun subtitleFor(playlist: PlaylistRef): String = when {
playlist.isSystem -> systemLabelFor(playlist.systemVariant)
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
else -> " "
}
private fun systemLabelFor(variant: String?): String = when (variant) {
"for_you" -> "For You"
"discover" -> "Discover"
"songs_like_artist" -> "Songs like…"
"todays_mix" -> "Today's mix"
null -> ""
else -> variant.replace('_', ' ').replaceFirstChar { it.uppercase() }
}