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