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.CachedAlbumDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
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.CachedResumeStateDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -58,5 +60,15 @@ object DatabaseModule {
|
|||||||
fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao =
|
fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao =
|
||||||
db.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"
|
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.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.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
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ private fun NavGraphBuilder.inShellTopLevel(
|
|||||||
}
|
}
|
||||||
composable<Playlists> {
|
composable<Playlists> {
|
||||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
ComingSoon("Playlists", "Lands in Phase 7.")
|
PlaylistsListScreen(navController = navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<Settings> {
|
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