feat(android): Phase 13 — Search screen + /api/search wiring
Replaces the Search-route ComingSoon stub with the real
three-facet search. Mirrors `flutter_client/lib/search/search_screen.dart`.
New:
- models/wire/SearchWire.kt — three concrete PagedXxxWire types
(Artists / Albums / Tracks) wrapping the server's Page[T] envelope,
plus SearchResponseWire. Going non-generic on the paged wrapper is
fine — three call sites and the explicit types read clearer than
a generic with manual type-arg gymnastics at decode time.
- models/SearchResponseRef.kt — domain envelope with `isEmpty` for
the screen's "no matches" branch.
- api/endpoints/SearchApi.kt — Retrofit GET /api/search with q +
limit + offset.
- search/data/SearchRepository.kt — trivial wire→domain mapper;
debouncing intentionally lives in the VM.
- search/ui/SearchViewModel.kt — 250ms debounce on the query Flow
via debounce() + distinctUntilChanged(). Empty query collapses
to Idle without firing a request. `playTrack(track)` queues a
single track via PlayerController for the same single-row-play
pattern as HistoryTab.
- search/ui/SearchScreen.kt — auto-focus the field on entry (the
user typed Search to start typing), inline X button to clear.
Results render as three sections (Artists / Albums / Tracks),
each section omitted when its list is empty. Artist taps →
ArtistDetail, album taps → AlbumDetail, track taps → play.
Modified:
- nav/MinstrelNavGraph.kt — Search route renders the real screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.SearchResponseWire
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for `GET /api/search`. Mirrors
|
||||||
|
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
|
||||||
|
* on empty/whitespace-only `q` — the caller is responsible for
|
||||||
|
* guarding.
|
||||||
|
*/
|
||||||
|
interface SearchApi {
|
||||||
|
@GET("api/search")
|
||||||
|
suspend fun search(
|
||||||
|
@Query("q") query: String,
|
||||||
|
@Query("limit") limit: Int = DEFAULT_LIMIT,
|
||||||
|
@Query("offset") offset: Int = 0,
|
||||||
|
): SearchResponseWire
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_LIMIT: Int = 20
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-facet search result. Each list is the first page returned by
|
||||||
|
* `/api/search`; deeper pages per facet are a future enhancement.
|
||||||
|
*
|
||||||
|
* `isEmpty` lets the screen pick between "no matches" and "type to
|
||||||
|
* search" copy.
|
||||||
|
*/
|
||||||
|
data class SearchResponseRef(
|
||||||
|
val artists: List<ArtistRef>,
|
||||||
|
val albums: List<AlbumRef>,
|
||||||
|
val tracks: List<TrackRef>,
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server's `Page[T]` envelope for search results — three concrete
|
||||||
|
* variants since kotlinx.serialization's generic support is fine but
|
||||||
|
* the three call sites are easier to read with explicit types.
|
||||||
|
*
|
||||||
|
* `total` reflects the full match count for that facet (not just
|
||||||
|
* what fit in `items` at this offset/limit). The mobile UI only
|
||||||
|
* walks the first page today; infinite scroll per facet is a future
|
||||||
|
* enhancement.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PagedArtistsWire(
|
||||||
|
val items: List<ArtistWire> = emptyList(),
|
||||||
|
val total: Int = 0,
|
||||||
|
val limit: Int = 0,
|
||||||
|
val offset: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PagedAlbumsWire(
|
||||||
|
val items: List<AlbumWire> = emptyList(),
|
||||||
|
val total: Int = 0,
|
||||||
|
val limit: Int = 0,
|
||||||
|
val offset: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PagedTracksWire(
|
||||||
|
val items: List<TrackWire> = emptyList(),
|
||||||
|
val total: Int = 0,
|
||||||
|
val limit: Int = 0,
|
||||||
|
val offset: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/search` response envelope. Three facets keyed by entity
|
||||||
|
* type, each independently paged.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SearchResponseWire(
|
||||||
|
val artists: PagedArtistsWire = PagedArtistsWire(),
|
||||||
|
val albums: PagedAlbumsWire = PagedAlbumsWire(),
|
||||||
|
val tracks: PagedTracksWire = PagedTracksWire(),
|
||||||
|
)
|
||||||
@@ -24,6 +24,7 @@ import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
|||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
|
||||||
import com.fabledsword.minstrel.requests.ui.RequestsScreen
|
import com.fabledsword.minstrel.requests.ui.RequestsScreen
|
||||||
|
import com.fabledsword.minstrel.search.ui.SearchScreen
|
||||||
import com.fabledsword.minstrel.settings.ui.SettingsScreen
|
import com.fabledsword.minstrel.settings.ui.SettingsScreen
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
||||||
@@ -70,7 +71,7 @@ private fun NavGraphBuilder.inShellTopLevel(
|
|||||||
}
|
}
|
||||||
composable<Search> {
|
composable<Search> {
|
||||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
ComingSoon("Search", "Lands in a later phase.")
|
SearchScreen(navController = navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<Discover> {
|
composable<Discover> {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.fabledsword.minstrel.search.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.SearchApi
|
||||||
|
import com.fabledsword.minstrel.library.data.toDomain
|
||||||
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in
|
||||||
|
* the ViewModel, not here, so the repository stays trivial.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SearchRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: SearchApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun search(query: String): SearchResponseRef {
|
||||||
|
val wire = api.search(query)
|
||||||
|
return SearchResponseRef(
|
||||||
|
artists = wire.artists.items.map { it.toDomain() },
|
||||||
|
albums = wire.albums.items.map { it.toDomain() },
|
||||||
|
tracks = wire.tracks.items.map { it.toDomain() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package com.fabledsword.minstrel.search.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.X
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
SearchField(
|
||||||
|
query = state.query,
|
||||||
|
onQueryChange = viewModel::setQuery,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.clear() }) {
|
||||||
|
Icon(Lucide.X, contentDescription = "Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||||
|
ResultsPane(
|
||||||
|
state = state.results,
|
||||||
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onTrackPlay = viewModel::playTrack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchField(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
placeholder = { Text("Search artists, albums, tracks") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultsPane(
|
||||||
|
state: SearchResultsState,
|
||||||
|
onArtistClick: (String) -> Unit,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
|
SearchResultsState.Loading -> LoadingCentered()
|
||||||
|
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
||||||
|
is SearchResultsState.Loaded -> {
|
||||||
|
if (state.response.isEmpty) {
|
||||||
|
CenteredHint("No matches for that query.")
|
||||||
|
} else {
|
||||||
|
ResultsList(state.response, onArtistClick, onAlbumClick, onTrackPlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultsList(
|
||||||
|
response: SearchResponseRef,
|
||||||
|
onArtistClick: (String) -> Unit,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
artistsSection(response.artists, onArtistClick)
|
||||||
|
albumsSection(response.albums, onAlbumClick)
|
||||||
|
tracksSection(response.tracks, onTrackPlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.artistsSection(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
onArtistClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (artists.isEmpty()) return
|
||||||
|
item { SectionHeader("Artists") }
|
||||||
|
items(items = artists, key = { "art-${it.id}" }) { artist ->
|
||||||
|
TextRow(
|
||||||
|
primary = artist.name,
|
||||||
|
secondary = null,
|
||||||
|
onClick = { onArtistClick(artist.id) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.albumsSection(
|
||||||
|
albums: List<AlbumRef>,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (albums.isEmpty()) return
|
||||||
|
item { SectionHeader("Albums") }
|
||||||
|
items(items = albums, key = { "alb-${it.id}" }) { album ->
|
||||||
|
TextRow(
|
||||||
|
primary = album.title,
|
||||||
|
secondary = album.artistName.takeIf { it.isNotEmpty() },
|
||||||
|
onClick = { onAlbumClick(album.id) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.tracksSection(
|
||||||
|
tracks: List<TrackRef>,
|
||||||
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
|
) {
|
||||||
|
if (tracks.isEmpty()) return
|
||||||
|
item { SectionHeader("Tracks") }
|
||||||
|
items(items = tracks, key = { "trk-${it.id}" }) { track ->
|
||||||
|
TextRow(
|
||||||
|
primary = track.title,
|
||||||
|
secondary = track.artistName.takeIf { it.isNotEmpty() }
|
||||||
|
?: track.albumTitle.takeIf { it.isNotEmpty() },
|
||||||
|
onClick = { onTrackPlay(track) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextRow(primary: String, secondary: String?, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = primary,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (!secondary.isNullOrEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = secondary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredHint(text: String) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingCentered() {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.fabledsword.minstrel.search.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
|
import com.fabledsword.minstrel.search.data.SearchRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val DEBOUNCE_MS = 250L
|
||||||
|
|
||||||
|
sealed interface SearchResultsState {
|
||||||
|
/** Empty query — screen shows "type to search" hint. */
|
||||||
|
data object Idle : SearchResultsState
|
||||||
|
data object Loading : SearchResultsState
|
||||||
|
data class Loaded(val response: SearchResponseRef) : SearchResultsState
|
||||||
|
data class Error(val message: String) : SearchResultsState
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SearchState(
|
||||||
|
val query: String = "",
|
||||||
|
val results: SearchResultsState = SearchResultsState.Idle,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class SearchViewModel @Inject constructor(
|
||||||
|
private val repository: SearchRepository,
|
||||||
|
private val player: PlayerController,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val queryFlow = MutableStateFlow("")
|
||||||
|
private val internal = MutableStateFlow(SearchState())
|
||||||
|
val state: StateFlow<SearchState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
queryFlow
|
||||||
|
.map { it.trim() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { q ->
|
||||||
|
// Empty query collapses results without firing the
|
||||||
|
// network call below.
|
||||||
|
if (q.isEmpty()) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(results = SearchResultsState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.debounce(DEBOUNCE_MS)
|
||||||
|
.collect { q ->
|
||||||
|
if (q.isEmpty()) return@collect
|
||||||
|
runSearch(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setQuery(value: String) {
|
||||||
|
internal.update { it.copy(query = value) }
|
||||||
|
queryFlow.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
setQuery("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playTrack(track: TrackRef) {
|
||||||
|
if (track.streamUrl.isEmpty()) return
|
||||||
|
player.setQueue(listOf(track), initialIndex = 0, source = "search")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSearch(q: String) {
|
||||||
|
internal.update { it.copy(results = SearchResultsState.Loading) }
|
||||||
|
try {
|
||||||
|
val response = repository.search(q)
|
||||||
|
internal.update { it.copy(results = SearchResultsState.Loaded(response)) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(results = SearchResultsState.Error(e.message ?: "Search failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user