diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt new file mode 100644 index 00000000..f1206862 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index 00e17a87..0647c3ab 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -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" } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt new file mode 100644 index 00000000..f362462b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt new file mode 100644 index 00000000..dd1b977f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt @@ -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 = emptyList(), + val public: List = 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 = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index c9a25bf6..9bbf2fc4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -13,6 +13,7 @@ import androidx.navigation.toRoute import com.fabledsword.minstrel.home.ui.HomeScreen import com.fabledsword.minstrel.library.ui.LibraryScreen import com.fabledsword.minstrel.player.ui.NowPlayingScreen +import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.ShellScaffold @@ -67,7 +68,7 @@ private fun NavGraphBuilder.inShellTopLevel( } composable { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Playlists", "Lands in Phase 7.") + PlaylistsListScreen(navController = navController) } } composable { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt new file mode 100644 index 00000000..94e89a60 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -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> = + playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + /** User-owned playlists only (systemVariant IS NULL). */ + fun observeUserPlaylists(): Flow> = + playlistDao.observeUserPlaylists().map { rows -> rows.map { it.toDomain() } } + + /** Server-generated system playlists (for_you / discover / etc.). */ + fun observeSystemPlaylists(): Flow> = + 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, +) + +// ── 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, + ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt new file mode 100644 index 00000000..962b6d2c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -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) : 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 = + 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, + 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) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt new file mode 100644 index 00000000..34132da0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -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() } +}