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:
@@ -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)
|
||||
}
|
||||
+25
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+123
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user