feat(android): Phase 10 — Discover screen (Lidarr search + suggestions + request create)

Adds the Discover surface — Lidarr search bar over an out-of-library
artist suggestion feed, with offline-first request-creation through
the MutationQueue. Mirrors `flutter_client/lib/discover/discover_screen.dart`.

New:
  - models/wire/DiscoverWire.kt — LidarrSearchResultWire,
    ArtistSuggestionWire (with SeedContribution children), and the
    CreateRequestBody POST shape.
  - models/Discover.kt — domain refs + LidarrRequestKind enum.
    ArtistSuggestionRef.attributionText preserves the Oxford-comma,
    "Because you liked X / played Y" phrasing from Flutter.
  - api/endpoints/DiscoverApi.kt — Retrofit: GET /api/discover/suggestions,
    GET /api/lidarr/search (q + kind), POST /api/requests.
  - discover/data/DiscoverRepository.kt — read-through search +
    suggestion + offline-first createRequest. Returns
    RequestOutcome.ACCEPTED on 2xx, RequestOutcome.QUEUED when the
    call got buffered into the MutationQueue. Same swallowed-exception
    rationale as LikesRepository.toggleLike.
  - discover/ui/DiscoverScreen.kt — VM + state + composable. Filter
    chips for Artists / Albums kind, search field with ImeAction.Search
    submit, results list vs. suggestion feed swap based on whether
    the query box is empty. Snackbar wording switches between
    "Requested: X" and "Request queued: X" based on outcome.
    Locally-just-requested MBIDs are tracked so the "Request" affordance
    swaps to a "Requested" pill instantly without waiting for a server
    refetch.

Modified:
  - cache/mutations/MutationQueue.kt — adds MutationKind.REQUEST_CREATE
    + RequestCreatePayload + enqueueRequestCreate helper.
  - nav/MinstrelNavGraph.kt — Discover route swaps from ComingSoon to
    `DiscoverScreen(navController = navController)`.

Requests screen (your-own requests list with status/cancel) and the
admin/quarantine slices land in follow-up commits within this phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:11:14 -04:00
parent d10f13c9b1
commit 734fc16ee6
7 changed files with 830 additions and 1 deletions
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
/**
* Retrofit interface for Discover / Lidarr search / request creation.
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
*
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
* of the same query are cheap.
*
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
* Commit B), not here.
*/
interface DiscoverApi {
@GET("api/discover/suggestions")
suspend fun listSuggestions(): List<ArtistSuggestionWire>
@GET("api/lidarr/search")
suspend fun search(
@Query("q") query: String,
@Query("kind") kind: String,
): List<LidarrSearchResultWire>
@POST("api/requests")
suspend fun createRequest(@Body body: CreateRequestBody)
}
@@ -15,6 +15,7 @@ import javax.inject.Singleton
*/
object MutationKind {
const val LIKE_TOGGLE: String = "like_toggle"
const val REQUEST_CREATE: String = "request_create"
}
/**
@@ -30,6 +31,23 @@ data class LikeTogglePayload(
val desiredState: Boolean,
)
/**
* Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
* Lidarr request that landed during a connectivity hiccup). Optional
* fields stay null when not applicable; the replayer rebuilds the
* `CreateRequestBody` from these.
*/
@Serializable
data class RequestCreatePayload(
val kind: String,
val artistMbid: String,
val artistName: String,
val albumMbid: String? = null,
val albumTitle: String? = null,
val trackMbid: String? = null,
val trackTitle: String? = null,
)
/**
* Minimal write-side of the offline mutation queue. Phase 8 v1 only
* enqueues — the connectivity-aware replayer that drains the queue
@@ -60,4 +78,11 @@ class MutationQueue @Inject constructor(
),
),
)
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.REQUEST_CREATE,
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
),
)
}
@@ -0,0 +1,123 @@
package com.fabledsword.minstrel.discover.data
import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SeedContributionRef
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.SeedContributionWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Outcome of a `createRequest` call — discriminates "server accepted"
* from "network down, queued for later replay". The UI uses this to
* pick between "Requested" and "Queued" snackbars.
*/
enum class RequestOutcome { ACCEPTED, QUEUED }
/**
* Read-through Discover surface: Lidarr search, suggestion feed, and
* the request-creation write path. Mirrors the Flutter
* `DiscoverApi` + `mutationQueueProvider` integration —
* `feedback_offline_first_for_server_writes` says writes never go
* fire-and-forget, so `createRequest` falls back to the
* MutationQueue on transport failure.
*
* Reads are not cached locally yet — discovery results are inherently
* transient (60s server-side LRU); the cache wins are minor and
* unblock no UX. Phase 13's offline pass adds an offline scrollback
* for the suggestion feed only.
*/
@Singleton
class DiscoverRepository @Inject constructor(
private val mutationQueue: MutationQueue,
retrofit: Retrofit,
) {
private val api: DiscoverApi = retrofit.create()
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
api.listSuggestions().map { it.toDomain() }
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
api.search(query = query, kind = kind.wire).map { it.toDomain() }
/**
* Fires `POST /api/requests`; on transport failure enqueues a
* `RequestCreatePayload` for the Phase-12.2 replayer to drain.
* Returns [RequestOutcome.ACCEPTED] when the server responded
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
*/
suspend fun createRequest(
kind: LidarrRequestKind,
artistMbid: String,
artistName: String,
albumMbid: String? = null,
albumTitle: String? = null,
trackMbid: String? = null,
trackTitle: String? = null,
): RequestOutcome {
val payload = RequestCreatePayload(
kind = kind.wire,
artistMbid = artistMbid,
artistName = artistName,
albumMbid = albumMbid?.ifEmpty { null },
albumTitle = albumTitle?.ifEmpty { null },
trackMbid = trackMbid?.ifEmpty { null },
trackTitle = trackTitle?.ifEmpty { null },
)
return try {
api.createRequest(payload.toBody())
RequestOutcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow — see LikesRepository.toggleLike for
// the same offline-first rationale.
mutationQueue.enqueueRequestCreate(payload)
RequestOutcome.QUEUED
}
}
}
// ── Mappers (internal — wire types stay out of UI) ──
private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef(
mbid = mbid,
name = name,
secondaryText = secondaryText,
imageUrl = imageUrl,
artistMbid = artistMbid,
albumMbid = albumMbid,
inLibrary = inLibrary,
requested = requested,
)
private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggestionRef(
mbid = mbid,
name = name,
imageUrl = imageUrl,
attribution = attribution.map { it.toDomain() },
)
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
name = name,
isLiked = isLiked,
)
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
kind = kind,
artistMbid = artistMbid,
artistName = artistName,
albumMbid = albumMbid,
albumTitle = albumTitle,
trackMbid = trackMbid,
trackTitle = trackTitle,
)
@@ -0,0 +1,513 @@
package com.fabledsword.minstrel.discover.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search as LucideSearch
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.discover.data.DiscoverRepository
import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
// ─── State ───────────────────────────────────────────────────────────
data class DiscoverState(
val query: String = "",
val kind: LidarrRequestKind = LidarrRequestKind.ARTIST,
val suggestions: SuggestionState = SuggestionState.Loading,
val results: ResultsState = ResultsState.Idle,
val locallyRequestedMbids: Set<String> = emptySet(),
)
sealed interface SuggestionState {
data object Loading : SuggestionState
data class Loaded(val items: List<ArtistSuggestionRef>) : SuggestionState
data class Error(val message: String) : SuggestionState
}
sealed interface ResultsState {
/** No active search — the screen shows the suggestion feed. */
data object Idle : ResultsState
data object Loading : ResultsState
data class Loaded(val items: List<LidarrSearchResultRef>) : ResultsState
data class Error(val message: String) : ResultsState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
class DiscoverViewModel @Inject constructor(
private val repository: DiscoverRepository,
) : ViewModel() {
private val internal = MutableStateFlow(DiscoverState())
val state: StateFlow<DiscoverState> = internal.asStateFlow()
init {
loadSuggestions()
}
fun setQuery(value: String) {
internal.update {
// Clearing the box returns to the suggestion feed.
val newResults = if (value.isBlank()) ResultsState.Idle else it.results
it.copy(query = value, results = newResults)
}
}
fun setKind(kind: LidarrRequestKind) {
internal.update { it.copy(kind = kind) }
if (internal.value.query.isNotBlank()) runSearch()
}
fun loadSuggestions() {
viewModelScope.launch {
internal.update { it.copy(suggestions = SuggestionState.Loading) }
try {
val items = repository.listSuggestions()
internal.update { it.copy(suggestions = SuggestionState.Loaded(items)) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(
suggestions = SuggestionState.Error(
e.message ?: "Suggestions failed",
),
)
}
}
}
}
fun runSearch() {
val q = internal.value.query.trim()
if (q.isEmpty()) {
internal.update { it.copy(results = ResultsState.Idle) }
return
}
viewModelScope.launch {
internal.update { it.copy(results = ResultsState.Loading) }
try {
val rows = repository.search(q, internal.value.kind)
internal.update { it.copy(results = ResultsState.Loaded(rows)) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(results = ResultsState.Error(e.message ?: "Search failed"))
}
}
}
}
/**
* Request a search-result row. Returns the outcome (ACCEPTED vs
* QUEUED) so the screen can pick the snackbar wording and update
* the local "requested" overlay.
*/
suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome {
val kind = internal.value.kind
val outcome = repository.createRequest(
kind = kind,
artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid,
artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText,
albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null,
albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null,
)
markRequested(row.mbid)
return outcome
}
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
val outcome = repository.createRequest(
kind = LidarrRequestKind.ARTIST,
artistMbid = s.mbid,
artistName = s.name,
)
markRequested(s.mbid)
return outcome
}
private fun markRequested(mbid: String) {
internal.update { it.copy(locallyRequestedMbids = it.locallyRequestedMbids + mbid) }
}
}
// ─── Screen ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiscoverScreen(
navController: NavHostController,
viewModel: DiscoverViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Discover") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Discover::class.qualifiedName,
)
},
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { inner ->
Column(modifier = Modifier.fillMaxSize().padding(inner)) {
SearchBar(
query = state.query,
onQueryChange = viewModel::setQuery,
onSubmit = viewModel::runSearch,
)
KindChips(kind = state.kind, onChange = viewModel::setKind)
Box(modifier = Modifier.fillMaxSize()) {
when (val r = state.results) {
ResultsState.Idle -> SuggestionsPane(
state = state.suggestions,
locallyRequestedMbids = state.locallyRequestedMbids,
onRequest = { s ->
scope.launch {
val outcome = viewModel.requestSuggestion(s)
snackbar.showSnackbar(snackbarFor(outcome, s.name))
}
},
)
ResultsState.Loading -> LoadingCentered()
is ResultsState.Loaded -> ResultsList(
rows = r.items,
locallyRequestedMbids = state.locallyRequestedMbids,
onRequest = { row ->
scope.launch {
val outcome = viewModel.requestRow(row)
snackbar.showSnackbar(snackbarFor(outcome, row.name))
}
},
)
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
}
}
}
}
}
@Composable
private fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSubmit: () -> Unit,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
placeholder = { Text("Search Lidarr for new music") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
trailingIcon = {
IconButton(onClick = onSubmit) {
Icon(Lucide.LucideSearch, contentDescription = "Search")
}
},
)
}
@Composable
private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = kind == LidarrRequestKind.ARTIST,
onClick = { onChange(LidarrRequestKind.ARTIST) },
label = { Text("Artists") },
)
FilterChip(
selected = kind == LidarrRequestKind.ALBUM,
onClick = { onChange(LidarrRequestKind.ALBUM) },
label = { Text("Albums") },
)
}
}
@Composable
private fun SuggestionsPane(
state: SuggestionState,
locallyRequestedMbids: Set<String>,
onRequest: (ArtistSuggestionRef) -> Unit,
) {
when (state) {
SuggestionState.Loading -> LoadingCentered()
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
is SuggestionState.Loaded -> SuggestionsList(
items = state.items.filter { it.mbid !in locallyRequestedMbids },
onRequest = onRequest,
)
}
}
@Composable
private fun SuggestionsList(
items: List<ArtistSuggestionRef>,
onRequest: (ArtistSuggestionRef) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
) {
item { SuggestionsHeader() }
if (items.isEmpty()) {
item { CenteredMessage("Listen to or like an artist to fill this in.") }
} else {
items(items = items, key = { it.mbid }) { s ->
SuggestionTile(s = s, onRequest = { onRequest(s) })
HorizontalDivider()
}
}
}
}
@Composable
private fun SuggestionsHeader() {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
text = "Suggested for you",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = "Out-of-library artists drawn from what you've liked and played.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ResultsList(
rows: List<LidarrSearchResultRef>,
locallyRequestedMbids: Set<String>,
onRequest: (LidarrSearchResultRef) -> Unit,
) {
if (rows.isEmpty()) {
CenteredMessage("Nothing to add for that search yet.")
return
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.mbid }) { row ->
ResultTile(
row = row,
locallyRequested = row.mbid in locallyRequestedMbids,
onRequest = { onRequest(row) },
)
HorizontalDivider()
}
}
}
@Composable
private fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(imageUrl = s.imageUrl, fallback = Lucide.User, shape = AvatarShape.CIRCLE)
Column(modifier = Modifier.weight(1f)) {
Text(
text = s.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (s.attributionText.isNotEmpty()) {
Text(
text = s.attributionText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Button(onClick = onRequest) { Text("Request") }
}
}
@Composable
private fun ResultTile(
row: LidarrSearchResultRef,
locallyRequested: Boolean,
onRequest: () -> Unit,
) {
val effectivelyRequested = row.requested || locallyRequested
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(imageUrl = row.imageUrl, fallback = Lucide.Disc3, shape = AvatarShape.ROUNDED)
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (row.secondaryText.isNotEmpty()) {
Text(
text = row.secondaryText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
when {
row.inLibrary -> AssistChip(onClick = {}, enabled = false, label = { Text("In library") })
effectivelyRequested -> AssistChip(onClick = {}, enabled = false, label = { Text("Requested") })
else -> Button(onClick = onRequest) { Text("Request") }
}
}
}
private enum class AvatarShape { CIRCLE, ROUNDED }
@Composable
private fun Avatar(
imageUrl: String,
fallback: androidx.compose.ui.graphics.vector.ImageVector,
shape: AvatarShape,
) {
val clip = when (shape) {
AvatarShape.CIRCLE -> CircleShape
AvatarShape.ROUNDED -> RoundedCornerShape(6.dp)
}
Box(
modifier = Modifier
.size(56.dp)
.clip(clip)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (imageUrl.isEmpty()) {
Icon(
imageVector = fallback,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@Composable
private fun CenteredMessage(text: String) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun snackbarFor(outcome: RequestOutcome, name: String): String = when (outcome) {
RequestOutcome.ACCEPTED -> "Requested: $name"
RequestOutcome.QUEUED -> "Request queued: $name"
}
@@ -0,0 +1,69 @@
package com.fabledsword.minstrel.models
/**
* Kind of Lidarr request being created. Wire form is the lowercase
* enum name; the helper [wire] keeps that mapping in one place.
*/
enum class LidarrRequestKind {
ARTIST, ALBUM, TRACK;
val wire: String get() = name.lowercase()
}
/**
* Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
* and `albumMbid` are filled when the row is an album/track and the
* UI needs the parent IDs to build the request.
*
* `inLibrary` and `requested` together cover the "already-handled"
* cases — UI greys the row + swaps the Request button for a pill.
*/
data class LidarrSearchResultRef(
val mbid: String,
val name: String,
val secondaryText: String = "",
val imageUrl: String = "",
val artistMbid: String = "",
val albumMbid: String = "",
val inLibrary: Boolean = false,
val requested: Boolean = false,
)
/**
* Per-seed attribution string for a suggestion — "you liked X" /
* "you played Y". `isLiked = true` discriminates the verb.
*/
data class SeedContributionRef(
val name: String,
val isLiked: Boolean,
)
/**
* Out-of-library artist suggestion from `GET /api/discover/suggestions`.
* `attributionText` mirrors Flutter's SuggestionFeed.attributionText
* (Oxford-comma, max 3 seeds) so the subtitle reads naturally.
*/
data class ArtistSuggestionRef(
val mbid: String,
val name: String,
val imageUrl: String = "",
val attribution: List<SeedContributionRef> = emptyList(),
) {
val attributionText: String
get() {
if (attribution.isEmpty()) return ""
val phrases = attribution
.take(MAX_ATTRIBUTION_PHRASES)
.map { "${if (it.isLiked) "liked" else "played"} ${it.name}" }
return when (phrases.size) {
1 -> "Because you ${phrases[0]}."
2 -> "Because you ${phrases[0]} and ${phrases[1]}."
else -> "Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}."
}
}
companion object {
private const val MAX_ATTRIBUTION_PHRASES = 3
}
}
@@ -0,0 +1,65 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One row of `GET /api/lidarr/search`. Mirrors
* `web/src/lib/api/types.ts LidarrSearchResult` /
* `flutter_client/lib/models/lidarr.dart LidarrSearchResult`.
*
* `inLibrary` and `requested` let the UI greyout rows the user can't
* act on (already imported / already awaiting review). All defaults
* empty so missing keys don't throw on decode.
*/
@Serializable
data class LidarrSearchResultWire(
val mbid: String = "",
val name: String = "",
@SerialName("secondary_text") val secondaryText: String = "",
@SerialName("image_url") val imageUrl: String = "",
@SerialName("artist_mbid") val artistMbid: String = "",
@SerialName("album_mbid") val albumMbid: String = "",
@SerialName("in_library") val inLibrary: Boolean = false,
val requested: Boolean = false,
)
/**
* `GET /api/discover/suggestions` row — an out-of-library artist
* surfaced from ListenBrainz-derived seeds. `imageUrl` is resolved
* on-demand from Lidarr server-side and may be empty.
*/
@Serializable
data class ArtistSuggestionWire(
val mbid: String = "",
val name: String = "",
@SerialName("image_url") val imageUrl: String = "",
val attribution: List<SeedContributionWire> = emptyList(),
)
/**
* Per-seed attribution for an ArtistSuggestion — "because you liked X"
* / "because you played Y". `isLiked = true` means the seed came from
* the user's likes; false means a played-but-not-liked play.
*/
@Serializable
data class SeedContributionWire(
val name: String = "",
@SerialName("is_liked") val isLiked: Boolean = false,
)
/**
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
* payload shape. Optional fields are emitted only when non-null
* server-side; matching that here keeps the wire identical.
*/
@Serializable
data class CreateRequestBody(
val kind: String,
@SerialName("lidarr_artist_mbid") val artistMbid: String,
@SerialName("artist_name") val artistName: String,
@SerialName("lidarr_album_mbid") val albumMbid: String? = null,
@SerialName("album_title") val albumTitle: String? = null,
@SerialName("lidarr_track_mbid") val trackMbid: String? = null,
@SerialName("track_title") val trackTitle: String? = null,
)
@@ -10,6 +10,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
import com.fabledsword.minstrel.home.ui.HomeScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
@@ -64,7 +65,7 @@ private fun NavGraphBuilder.inShellTopLevel(
}
composable<Discover> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Discover", "Lands in Phase 10 (Lidarr requests).")
DiscoverScreen(navController = navController)
}
}
composable<Playlists> {