From 734fc16ee6e88165af77d6f17ad81eb033193139 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 00:11:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2010=20=E2=80=94=20Disco?= =?UTF-8?q?ver=20screen=20(Lidarr=20search=20+=20suggestions=20+=20request?= =?UTF-8?q?=20create)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/api/endpoints/DiscoverApi.kt | 33 ++ .../minstrel/cache/mutations/MutationQueue.kt | 25 + .../discover/data/DiscoverRepository.kt | 123 +++++ .../minstrel/discover/ui/DiscoverScreen.kt | 513 ++++++++++++++++++ .../fabledsword/minstrel/models/Discover.kt | 69 +++ .../minstrel/models/wire/DiscoverWire.kt | 65 +++ .../minstrel/nav/MinstrelNavGraph.kt | 3 +- 7 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt new file mode 100644 index 00000000..9614eacf --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt @@ -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 + + @GET("api/lidarr/search") + suspend fun search( + @Query("q") query: String, + @Query("kind") kind: String, + ): List + + @POST("api/requests") + suspend fun createRequest(@Body body: CreateRequestBody) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index 13226834..fc463185 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -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), + ), + ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt new file mode 100644 index 00000000..2d561fd4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt @@ -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 = + api.listSuggestions().map { it.toDomain() } + + suspend fun search(query: String, kind: LidarrRequestKind): List = + 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt new file mode 100644 index 00000000..a2a1c384 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt @@ -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 = emptySet(), +) + +sealed interface SuggestionState { + data object Loading : SuggestionState + data class Loaded(val items: List) : 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) : 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 = 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, + 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, + 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, + locallyRequestedMbids: Set, + 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" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt new file mode 100644 index 00000000..b04e9fe8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt @@ -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 = 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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt new file mode 100644 index 00000000..bf09d37d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt @@ -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 = 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index 72f3a160..1a5888f5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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 { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Discover", "Lands in Phase 10 (Lidarr requests).") + DiscoverScreen(navController = navController) } } composable {