From 410ecd893cb0efa80658bc85fffd4d2ea8ff2b8a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 13 Aug 2026 14:19:26 +0200 Subject: [PATCH 1/2] feat(serveur): chercher dans le catalogue distant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux recherches séparées, et non une seule fusionnée. La RFC-003 interdit de deviner qu'une piste locale et une piste serveur sont le même enregistrement : une liste de résultats mêlés montrerait deux fois le même album sans rien pour l'expliquer, et poserait à l'écran la question à laquelle le protocole ne répond pas encore. La loupe de la barre du haut reste donc locale ; l'onglet Serveur reçoit son propre champ. Quand la recherche locale ne trouve rien et qu'un serveur est connecté, un bouton « Chercher sur le serveur » passe la main — explicitement, plutôt que des résultats distants qui s'ajouteraient d'eux-mêmes. Sans serveur, pas de bouton : il mènerait à un écran de connexion que personne n'a demandé. Contrairement à la recherche locale, qui filtre en mémoire, chaque frappe partirait ici sur le réseau : la requête est retenue 300 ms et `flatMapLatest` abandonne la précédente. Les mises à jour d'état sont des transformations et non des états complets, sans quoi une réponse tardive écraserait la requête que l'utilisateur continue de taper. `SearchField` ne prend plus le focus de force : c'était juste pour une barre qu'on ouvre, gênant pour un champ permanent dont la seule présence ferait monter le clavier à chaque visite de l'onglet. Le test qui épingle l'anti-rebond a d'abord été creux : `advanceUntilIdle` avance le temps virtuel sans limite, un délai de cinq minutes y passait inaperçu. Il avance désormais par paliers mesurés, et tombe aussi bien si le délai disparaît que s'il explose. Validé contre un waveflow-server local : « echo » et « Écho » trouvent la même chose — les accents sont repliés — et « zzz » ne trouve rien. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- README.md | 14 +- .../main/java/app/waveflow/MainActivity.kt | 18 ++ .../app/waveflow/data/remote/CatalogApi.kt | 14 ++ .../waveflow/data/remote/CatalogRepository.kt | 4 + .../main/java/app/waveflow/data/remote/Dto.kt | 8 + .../waveflow/data/remote/HttpCatalogApi.kt | 22 ++ .../java/app/waveflow/model/RemoteCatalog.kt | 16 ++ .../app/waveflow/ui/search/SearchField.kt | 8 +- .../app/waveflow/ui/search/SearchScreen.kt | 16 +- .../ui/server/catalog/CatalogUiState.kt | 20 ++ .../ui/server/catalog/CatalogViewModel.kt | 65 +++++ .../ui/server/catalog/ServerCatalogScreen.kt | 103 ++++++++ .../java/app/waveflow/testing/ServerFakes.kt | 35 +++ .../ui/server/catalog/CatalogSearchTest.kt | 225 ++++++++++++++++++ 14 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt diff --git a/README.md b/README.md index 4508b3a..b59485a 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ in-memory SQLite for Room, so the DAO is exercised without a device. | `HttpCatalogApiTest` | paging params, flattened details, track ordering | | `CatalogRepositoryTest` | token plumbing, retry after a refused token | | `CatalogViewModelTest` | paging, end of list, in-flight guard, clear on sign-out | +| `CatalogSearchTest` | debounce pinned both ways, blank queries, failures, sign-out | | `MediaItemMapperTest` | local vs remote track identity, unreachable marker URI | | `RemoteStreamResolverTest` | ticket swap, local passthrough, DataSpec preserved | | `RemoteAlbumDetailScreenTest` | Play and Shuffle are distinct, empty album, track tap | @@ -159,7 +160,7 @@ into `DragState` and tested there instead. - [x] Album / artist browsing (Navigation Compose + bottom bar) - [x] Local playlists (Room): create, rename, delete, add / remove tracks - [x] Drag-to-reorder inside a playlist -- [x] Search across songs, albums and artists +- [x] Search across songs, albums and artists (device and server, separately) - [x] Compose UI tests (Robolectric, no device) - [x] Sign in to a WaveFlow server (session, refresh, sign-out) - [x] Browse the server catalogue (albums, artists, paginated) @@ -175,6 +176,17 @@ and browses its catalogue — albums, artists, and what each contains. A remote album plays like a local one: tap a track, or use Play / Shuffle. Nothing of the local library is sent anywhere. +Search stays split in two. The magnifier in the top bar searches the device; the +Server tab has its own field. RFC-003 forbids guessing that a local track and a +server track are the same recording, so a single merged result list would show +the same album twice with nothing to explain why — it would pose on screen the +question the protocol has not answered. When a device search finds nothing and a +server is connected, an explicit *Chercher sur le serveur* button hands the +query over; results never drift in on their own. + +Unlike the local search, which filters in memory, each keystroke here would hit +the network, so the query is debounced and the previous request is dropped. + The two sources stay separate by design: the tab is its own section rather than a filter over the existing screens, and `RemoteAlbum` / `RemoteArtist` / `RemoteSong` are distinct types from their local counterparts. Their ids are diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index e2c6ba1..18a405c 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -400,10 +400,17 @@ private fun WaveFlowRoot() { .collectAsStateWithLifecycle() val remoteArtists by catalogViewModel.artists .collectAsStateWithLifecycle() + val remoteSearch by catalogViewModel.search + .collectAsStateWithLifecycle() ServerCatalogScreen( albums = remoteAlbums, artists = remoteArtists, + search = remoteSearch, + onSearchQueryChange = catalogViewModel::onSearchQueryChange, + // Un résultat de recherche se lit seul : la file + // est ce morceau, faute de contexte d'album. + onSongClick = { playerViewModel.playRemoteFrom(listOf(it), it) }, onAlbumClick = { navController.navigate(Routes.serverAlbumDetail(it.id)) }, @@ -543,6 +550,17 @@ private fun WaveFlowRoot() { }, bottomPadding = listBottomPadding, onSongLongClick = { songToAdd = it }, + // Proposé seulement quand un serveur est connecté : + // sinon le bouton mènerait à un écran de connexion, + // que l'utilisateur n'a pas demandé. + onSearchOnServer = serverState.connected?.let { + { + val terme = searchQuery + closeSearch() + catalogViewModel.onSearchQueryChange(terme) + navController.switchTab(TopLevelDestination.Server) + } + }, ) } } diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt index d89fe18..a121ea5 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -4,6 +4,7 @@ import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults /** * Lecture du catalogue d'un serveur WaveFlow. @@ -36,6 +37,19 @@ interface CatalogApi { /** `GET /api/v2/artists/{id}` : l'artiste et ses albums. */ suspend fun artist(serverUrl: String, accessToken: String, artistId: String): RemoteArtistDetail + /** + * `GET /api/v2/search`, sur les trois types à la fois. + * + * La pertinence est celle de l'index du serveur : rien n'est reclassé ici. + */ + suspend fun search( + serverUrl: String, + accessToken: String, + query: String, + offset: Int, + limit: Int, + ): RemoteSearchResults + /** * `POST /api/v2/tracks/{id}/stream-ticket`. * diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt index 1920e23..08d1635 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -4,6 +4,7 @@ import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults import app.waveflow.model.ServerSession /** @@ -29,6 +30,9 @@ class CatalogRepository( suspend fun artist(artistId: String): RemoteArtistDetail = authorized { url, token -> api.artist(url, token, artistId) } + suspend fun search(query: String, limit: Int = CATALOG_PAGE_SIZE): RemoteSearchResults = + authorized { url, token -> api.search(url, token, query, offset = 0, limit = limit) } + /** * URL de diffusion d'une piste, valable une heure côté serveur. * diff --git a/app/src/main/java/app/waveflow/data/remote/Dto.kt b/app/src/main/java/app/waveflow/data/remote/Dto.kt index 64f09dc..425a532 100644 --- a/app/src/main/java/app/waveflow/data/remote/Dto.kt +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -124,6 +124,14 @@ internal data class AlbumDetailResponse( get() = AlbumResponse(id, title, artist, artistId, year, artworkHash) } +/** `/api/v2/search` : les trois types dans une seule réponse. */ +@Serializable +internal data class SearchResponse( + val artists: List = emptyList(), + val albums: List = emptyList(), + val songs: List = emptyList(), +) + /** `{"url": "/api/v2/stream/", "expires_at": }` — l'URL est relative. */ @Serializable internal data class StreamTicketResponse( diff --git a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt index 5439172..4f3712a 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -4,6 +4,7 @@ import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults import kotlinx.serialization.SerializationException /** Catalogue distant, par-dessus [ServerHttp]. */ @@ -71,6 +72,26 @@ class HttpCatalogApi( ) } + override suspend fun search( + serverUrl: String, + accessToken: String, + query: String, + offset: Int, + limit: Int, + ): RemoteSearchResults = http.get( + serverUrl = serverUrl, + path = SEARCH, + query = pageQuery(offset, limit) + ("q" to query), + accessToken = accessToken, + ).decode().let { response -> + val artwork = artwork(serverUrl) + RemoteSearchResults( + songs = response.songs.map { it.toModel(artwork) }, + albums = response.albums.map { it.toModel(artwork) }, + artists = response.artists.map { it.toModel(artwork) }, + ) + } + override suspend fun streamTicket( serverUrl: String, accessToken: String, @@ -103,6 +124,7 @@ class HttpCatalogApi( const val ALBUMS = "api/v2/albums" const val ARTISTS = "api/v2/artists" const val TRACKS = "api/v2/tracks" + const val SEARCH = "api/v2/search" /** Sans numéro de piste, on retombe sur le titre plutôt que sur rien. */ val BY_TRACK_THEN_TITLE = compareBy( diff --git a/app/src/main/java/app/waveflow/model/RemoteCatalog.kt b/app/src/main/java/app/waveflow/model/RemoteCatalog.kt index 26ca2e5..213f6a8 100644 --- a/app/src/main/java/app/waveflow/model/RemoteCatalog.kt +++ b/app/src/main/java/app/waveflow/model/RemoteCatalog.kt @@ -53,3 +53,19 @@ data class RemoteArtistDetail( val artist: RemoteArtist, val albums: List, ) + +/** + * Résultats d'une recherche sur le serveur. + * + * Le serveur les rend groupés par type et déjà classés : contrairement à la + * recherche locale, rien n'est trié ici — c'est son index qui décide de la + * pertinence. + */ +data class RemoteSearchResults( + val songs: List = emptyList(), + val albums: List = emptyList(), + val artists: List = emptyList(), +) { + val isEmpty: Boolean + get() = songs.isEmpty() && albums.isEmpty() && artists.isEmpty() +} diff --git a/app/src/main/java/app/waveflow/ui/search/SearchField.kt b/app/src/main/java/app/waveflow/ui/search/SearchField.kt index ae63be0..562fc81 100644 --- a/app/src/main/java/app/waveflow/ui/search/SearchField.kt +++ b/app/src/main/java/app/waveflow/ui/search/SearchField.kt @@ -26,12 +26,18 @@ fun SearchField( query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier, + /** + * `false` pour un champ permanent, qui fait déjà partie de l'écran. + * Prendre le focus n'a de sens que pour une barre qu'on vient d'ouvrir — + * sinon le clavier monte à chaque visite de l'onglet. + */ + autoFocus: Boolean = true, ) { val focusRequester = remember { FocusRequester() } // Le champ s'ouvre prêt à recevoir la frappe : sans ça, ouvrir la // recherche demanderait un second appui pour faire monter le clavier. - LaunchedEffect(Unit) { focusRequester.requestFocus() } + LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } TextField( value = query, diff --git a/app/src/main/java/app/waveflow/ui/search/SearchScreen.kt b/app/src/main/java/app/waveflow/ui/search/SearchScreen.kt index fa7c5f6..7e254a9 100644 --- a/app/src/main/java/app/waveflow/ui/search/SearchScreen.kt +++ b/app/src/main/java/app/waveflow/ui/search/SearchScreen.kt @@ -20,6 +20,8 @@ import app.waveflow.model.Artist import app.waveflow.model.SearchResults import app.waveflow.model.Song import app.waveflow.ui.albumCountLabel +import androidx.compose.material3.Button +import androidx.compose.material3.Text import app.waveflow.ui.components.CenteredMessage import app.waveflow.ui.components.MediaRow import app.waveflow.ui.components.SongRow @@ -43,6 +45,13 @@ fun SearchScreen( modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp, onSongLongClick: (Song) -> Unit = {}, + /** + * Proposé quand la recherche locale ne trouve rien et qu'un serveur est + * connecté. Un bouton explicite plutôt que des résultats distants qui + * s'ajouteraient d'eux-mêmes : les deux sources restent séparées, et + * l'utilisateur sait toujours où il cherche. + */ + onSearchOnServer: (() -> Unit)? = null, ) { if (query.isBlank() || results.isEmpty) { Box(modifier = modifier.fillMaxSize()) { @@ -50,9 +59,14 @@ fun SearchScreen( message = if (query.isBlank()) { "Cherchez un titre, un album ou un artiste." } else { - "Aucun résultat pour « ${query.trim()} »." + "Aucun résultat pour « ${query.trim()} » sur cet appareil." }, modifier = Modifier.align(Alignment.Center), + action = onSearchOnServer + ?.takeIf { query.isNotBlank() } + ?.let { chercher -> + { Button(onClick = chercher) { Text("Chercher sur le serveur") } } + }, ) } return diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt index b949e91..f231a08 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt @@ -2,6 +2,7 @@ package app.waveflow.ui.server.catalog import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults /** * Une liste paginée en cours de chargement. @@ -32,6 +33,25 @@ data class DetailState( val errorMessage: String? = null, ) +/** + * Recherche sur le serveur. + * + * Distincte de la recherche locale, qui filtre en mémoire : celle-ci part sur + * le réseau, peut échouer, et met du temps. L'écran doit pouvoir le montrer. + */ +data class RemoteSearchState( + val query: String = "", + val results: RemoteSearchResults = RemoteSearchResults(), + val isSearching: Boolean = false, + val errorMessage: String? = null, +) { + val isActive: Boolean get() = query.isNotBlank() + + /** Vrai quand la recherche a abouti sans rien trouver. */ + val foundNothing: Boolean + get() = isActive && !isSearching && errorMessage == null && results.isEmpty +} + typealias AlbumDetailState = DetailState typealias ArtistDetailState = DetailState diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt index 209d403..ba90bea 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt @@ -12,10 +12,18 @@ import app.waveflow.data.remote.CatalogRepository import app.waveflow.data.remote.ServerException import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteSearchResults import app.waveflow.model.ServerSession import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -30,6 +38,7 @@ import kotlinx.coroutines.launch * Les pages ne sont pas conservées à la déconnexion : elles appartiennent à un * compte, et l'écran suivant pourrait être celui d'un autre. */ +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) class CatalogViewModel( private val catalogRepository: CatalogRepository, session: StateFlow, @@ -47,6 +56,9 @@ class CatalogViewModel( private val _artistDetail = MutableStateFlow(ArtistDetailState()) val artistDetail: StateFlow = _artistDetail.asStateFlow() + private val _search = MutableStateFlow(RemoteSearchState()) + val search: StateFlow = _search.asStateFlow() + /** Une seule page en vol par liste : deux requêtes doubleraient le contenu. */ private var albumsJob: Job? = null private var artistsJob: Job? = null @@ -67,6 +79,55 @@ class CatalogViewModel( .distinctUntilChanged() .onEach { connected -> if (connected) loadFirstPages() else clear() } .launchIn(viewModelScope) + + // Contrairement à la recherche locale, qui filtre en mémoire à chaque + // frappe, celle-ci part sur le réseau : sans délai de grâce, taper + // « écho » lancerait quatre requêtes dont trois inutiles. + // `flatMapLatest` abandonne la précédente dès qu'une frappe arrive. + _search + .map { it.query.trim() } + .distinctUntilChanged() + .debounce { if (it.isBlank()) 0L else SEARCH_DEBOUNCE_MS } + .flatMapLatest { query -> searchFlow(query) } + .onEach { outcome -> _search.update { outcome(it) } } + .launchIn(viewModelScope) + } + + fun onSearchQueryChange(query: String) { + _search.update { it.copy(query = query) } + } + + fun clearSearch() { + _search.value = RemoteSearchState() + } + + /** + * Une recherche, sous forme de mises à jour successives de l'état. + * + * Renvoyer des transformations plutôt que des états complets évite d'écraser + * la requête que l'utilisateur continue de taper pendant l'appel. + */ + private fun searchFlow(query: String): Flow<(RemoteSearchState) -> RemoteSearchState> = flow { + if (query.isBlank()) { + emit { it.copy(results = RemoteSearchResults(), isSearching = false, errorMessage = null) } + return@flow + } + + emit { it.copy(isSearching = true, errorMessage = null) } + try { + val results = catalogRepository.search(query) + emit { it.copy(results = results, isSearching = false) } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + emit { + it.copy( + results = RemoteSearchResults(), + isSearching = false, + errorMessage = error.toMessage(), + ) + } + } } /** Charge la page suivante d'albums, si elle a lieu d'être. */ @@ -168,6 +229,7 @@ class CatalogViewModel( _artists.value = PagedList() _albumDetail.value = AlbumDetailState() _artistDetail.value = ArtistDetailState() + _search.value = RemoteSearchState() } private fun Exception.toMessage(): String = when (this) { @@ -185,6 +247,9 @@ class CatalogViewModel( companion object { private const val TAG = "CatalogViewModel" + /** De quoi laisser finir un mot avant d'interroger le serveur. */ + private const val SEARCH_DEBOUNCE_MS = 300L + val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt index 3389449..e05ccf8 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt @@ -1,28 +1,36 @@ package app.waveflow.ui.server.catalog +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Tab import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteSong import app.waveflow.model.orUnknownArtist import app.waveflow.ui.albumCountLabel +import app.waveflow.ui.components.CenteredMessage import app.waveflow.ui.components.MediaRow +import app.waveflow.ui.search.SearchField private enum class CatalogTab(val label: String) { Albums("Albums"), @@ -40,6 +48,9 @@ private enum class CatalogTab(val label: String) { fun ServerCatalogScreen( albums: PagedList, artists: PagedList, + search: RemoteSearchState, + onSearchQueryChange: (String) -> Unit, + onSongClick: (RemoteSong) -> Unit, onAlbumClick: (RemoteAlbum) -> Unit, onArtistClick: (RemoteArtist) -> Unit, onLoadMoreAlbums: () -> Unit, @@ -58,6 +69,29 @@ fun ServerCatalogScreen( val artistsListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } Column(modifier = modifier.fillMaxSize()) { + // Le champ vit dans la section Serveur : aucune ambiguïté sur ce qu'il + // cherche, contrairement à la loupe de la barre du haut qui, elle, + // reste locale. + SearchField( + query = search.query, + onQueryChange = onSearchQueryChange, + autoFocus = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + + if (search.isActive) { + RemoteSearchResultsList( + state = search, + onSongClick = onSongClick, + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + bottomPadding = bottomPadding, + ) + return@Column + } + PrimaryTabRow(selectedTabIndex = tab.ordinal) { CatalogTab.entries.forEach { entry -> Tab( @@ -159,3 +193,72 @@ private fun ArtistsTab( } } + +/** + * Résultats d'une recherche sur le serveur. + * + * Les trois types partagent une seule liste, comme la recherche locale : un + * album qui n'apparaît qu'après vingt titres reste atteignable en continuant de + * faire défiler. + */ +@Composable +private fun RemoteSearchResultsList( + state: RemoteSearchState, + onSongClick: (RemoteSong) -> Unit, + onAlbumClick: (RemoteAlbum) -> Unit, + onArtistClick: (RemoteArtist) -> Unit, + bottomPadding: Dp, +) { + when { + state.errorMessage != null -> Box(modifier = Modifier.fillMaxSize()) { + CenteredMessage( + message = state.errorMessage, + modifier = Modifier.align(Alignment.Center), + ) + } + + // L'indicateur n'apparaît qu'à défaut de résultats : pendant une frappe + // suivante, les précédents restent lisibles plutôt que de clignoter. + state.isSearching && state.results.isEmpty -> Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + state.foundNothing -> Box(modifier = Modifier.fillMaxSize()) { + CenteredMessage( + message = "Aucun résultat sur le serveur pour « ${state.query.trim()} ».", + modifier = Modifier.align(Alignment.Center), + ) + } + + else -> LazyColumn( + contentPadding = PaddingValues(bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(state.results.songs, key = { "song-${it.id}" }) { song -> + MediaRow( + artworkUri = song.artworkUri, + title = song.title, + subtitle = song.artist.orUnknownArtist(), + onClick = { onSongClick(song) }, + ) + } + items(state.results.albums, key = { "album-${it.id}" }) { album -> + MediaRow( + artworkUri = album.artworkUri, + title = album.title, + subtitle = album.artist.orUnknownArtist(), + onClick = { onAlbumClick(album) }, + ) + } + items(state.results.artists, key = { "artist-${it.id}" }) { artist -> + MediaRow( + artworkUri = artist.artworkUri, + title = artist.name, + subtitle = artist.albumCount?.let(::albumCountLabel).orEmpty(), + onClick = { onArtistClick(artist) }, + artworkShape = CircleShape, + ) + } + } + } +} diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 05f7ba6..ba1db15 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -9,6 +9,8 @@ import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults +import app.waveflow.model.RemoteSong import app.waveflow.model.ServerSession import kotlinx.coroutines.CompletableDeferred @@ -151,6 +153,17 @@ class FakeCatalogApi( ) } + override suspend fun search( + serverUrl: String, + accessToken: String, + query: String, + offset: Int, + limit: Int, + ): RemoteSearchResults { + record(serverUrl, accessToken, offset to limit) + return RemoteSearchResults() + } + override suspend fun streamTicket( serverUrl: String, accessToken: String, @@ -195,6 +208,10 @@ class PagingCatalogApi( private val gate: CompletableDeferred? = null, /** Même rôle que [gate], pour les détails. */ private val detailGate: CompletableDeferred? = null, + /** Même rôle, pour la recherche. */ + private val searchGate: CompletableDeferred? = null, + private val searchFailure: Throwable? = null, + private val searchResults: List = emptyList(), ) : CatalogApi { var albumCalls = 0 @@ -258,6 +275,24 @@ class PagingCatalogApi( ) } + /** Requêtes reçues, dans l'ordre : c'est l'anti-rebond qu'on éprouve. */ + val searchQueries = mutableListOf() + + override suspend fun search( + serverUrl: String, + accessToken: String, + query: String, + offset: Int, + limit: Int, + ): RemoteSearchResults { + searchQueries += query + searchGate?.await() + searchFailure?.let { throw it } + return RemoteSearchResults( + songs = searchResults.filter { it.title.contains(query, ignoreCase = true) }, + ) + } + override suspend fun streamTicket( serverUrl: String, accessToken: String, diff --git a/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt new file mode 100644 index 0000000..839570f --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt @@ -0,0 +1,225 @@ +package app.waveflow.ui.server.catalog + +import app.waveflow.data.remote.CatalogRepository +import app.waveflow.data.remote.ServerException +import app.waveflow.data.remote.ServerSessionRepository +import app.waveflow.model.ServerSession +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import app.waveflow.testing.MainDispatcherRule +import app.waveflow.testing.PagingCatalogApi +import app.waveflow.testing.remoteSong +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * La recherche sur le serveur. + * + * Contrairement à la recherche locale, qui filtre en mémoire, chaque frappe + * pourrait partir sur le réseau : c'est surtout l'anti-rebond qu'on vérifie ici. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class CatalogSearchTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val connected = ServerSession.Connected( + serverUrl = "https://musique.test", + username = "admin", + accessToken = "wfa_1", + refreshToken = "wfr_1", + deviceId = "appareil-1", + accessExpiresAtMs = Long.MAX_VALUE, + ) + + private val catalogue = listOf( + remoteSong(id = "a", title = "Écho lointain"), + remoteSong(id = "b", title = "Résonance"), + ) + + private suspend fun viewModel(catalog: PagingCatalogApi): CatalogViewModel { + val sessions = ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(stored = connected), + deviceName = "Pixel de test", + now = { 0L }, + ) + sessions.restore() + return CatalogViewModel( + CatalogRepository(catalog, sessions), + MutableStateFlow(connected), + ) + } + + @Test + fun `une requete interroge le serveur et expose ses resultats`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + + val state = viewModel.search.value + assertEquals(listOf("Écho"), catalog.searchQueries) + assertEquals(listOf("Écho lointain"), state.results.songs.map { it.title }) + assertFalse(state.isSearching) + } + + @Test + fun `taper vite ne lance qu'une requete`() = + runTest(mainDispatcherRule.dispatcher) { + // Le cas qui compte : sur le réseau, une requête par frappe est un + // gâchis que la recherche locale, en mémoire, ne connaît pas. + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + "Écho".forEachIndexed { index, _ -> + viewModel.onSearchQueryChange("Écho".take(index + 1)) + advanceTimeBy(50) + } + advanceUntilIdle() + + assertEquals(listOf("Écho"), catalog.searchQueries) + } + + @Test + fun `la requete attend la fin de la frappe, sans plus`() = + runTest(mainDispatcherRule.dispatcher) { + // Épingle le délai dans les deux sens. `advanceUntilIdle` avance le + // temps virtuel sans limite : un anti-rebond démesuré y passerait + // inaperçu, seule une avance mesurée le révèle. + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceTimeBy(200) + runCurrent() + assertTrue("rien tant que la frappe peut continuer", catalog.searchQueries.isEmpty()) + + advanceTimeBy(200) + runCurrent() + assertEquals(listOf("Écho"), catalog.searchQueries) + } + + @Test + fun `une requete differente relance la recherche`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + viewModel.onSearchQueryChange("Réso") + advanceUntilIdle() + + assertEquals(listOf("Écho", "Réso"), catalog.searchQueries) + } + + @Test + fun `vider la requete n'interroge pas le serveur et efface les resultats`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + + viewModel.onSearchQueryChange("") + advanceUntilIdle() + + assertEquals("une requête vide ne vaut pas un appel", 1, catalog.searchQueries.size) + assertTrue(viewModel.search.value.results.isEmpty) + assertFalse(viewModel.search.value.isActive) + } + + @Test + fun `les espaces seuls ne valent pas une requete`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange(" ") + advanceUntilIdle() + + assertTrue(catalog.searchQueries.isEmpty()) + } + + @Test + fun `la frappe qui continue pendant l'appel n'est pas ecrasee`() = + runTest(mainDispatcherRule.dispatcher) { + // L'état porte à la fois la requête et les résultats : les remplacer + // ensemble ferait reculer le curseur de l'utilisateur. + val portail = CompletableDeferred() + val catalog = PagingCatalogApi(searchResults = catalogue, searchGate = portail) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceTimeBy(400) + runCurrent() + + viewModel.onSearchQueryChange("Écho lo") + portail.complete(Unit) + advanceUntilIdle() + + assertEquals("Écho lo", viewModel.search.value.query) + } + + @Test + fun `un echec devient un message et non des resultats vides`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi( + searchResults = catalogue, + searchFailure = ServerException.Unreachable("coupure"), + ) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + + val state = viewModel.search.value + assertEquals("Serveur injoignable.", state.errorMessage) + assertFalse("un échec n'est pas une absence de résultat", state.foundNothing) + } + + @Test + fun `la deconnexion efface la recherche`() = + runTest(mainDispatcherRule.dispatcher) { + val session = MutableStateFlow(connected) + val sessions = ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(stored = connected), + deviceName = "Pixel de test", + now = { 0L }, + ) + sessions.restore() + val viewModel = CatalogViewModel( + CatalogRepository(PagingCatalogApi(searchResults = catalogue), sessions), + session, + ) + + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + + session.value = ServerSession.Disconnected + advanceUntilIdle() + + assertEquals("", viewModel.search.value.query) + assertTrue(viewModel.search.value.results.isEmpty) + assertNull(viewModel.search.value.errorMessage) + } +} From bcc26c11081e6435c656ccbc741c0f0b9dc7a799 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 13 Aug 2026 23:23:29 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(serveur):=20annuler=20vraiment=20la=20r?= =?UTF-8?q?echerche=20pr=C3=A9c=C3=A9dente=20=C3=A0=20la=20frappe=20suivan?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le délai de grâce était posé avant `flatMapLatest`. Il retardait donc aussi l'arrivée de la nouvelle requête, pendant que la précédente continuait de courir : jusqu'à rendre ses résultats sous une requête déjà changée, l'écran montrant alors les titres d'un terme sous un autre. Placé dans le flux créé par `flatMapLatest`, une frappe annule d'un coup l'attente et l'appel en vol. La liste des résultats revient en haut à chaque nouvelle requête. Rester au milieu de l'ancienne ferait manquer les premiers résultats, ceux que le serveur juge les plus pertinents. Le faux catalogue pagine désormais aussi ses résultats de recherche, au lieu d'ignorer l'offset et la limite qu'on lui passe. Le test du délai avance de 299 ms puis d'une seule : la valeur est épinglée, et non un intervalle où n'importe quelle constante passerait. Le test de l'annulation a d'abord été creux — les deux montages convergent vers le même état final, seul l'instant intermédiaire les départage. Il observe maintenant ce moment précis, et tombe si le délai retourne avant `flatMapLatest`. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- .../ui/server/catalog/CatalogViewModel.kt | 17 +++++-- .../ui/server/catalog/ServerCatalogScreen.kt | 20 ++++++-- .../java/app/waveflow/testing/ServerFakes.kt | 4 +- .../ui/server/catalog/CatalogSearchTest.kt | 48 ++++++++++++++++++- 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt index ba90bea..7933ef3 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt @@ -15,12 +15,11 @@ import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteSearchResults import app.waveflow.model.ServerSession import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.Job import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update @@ -38,7 +37,7 @@ import kotlinx.coroutines.launch * Les pages ne sont pas conservées à la déconnexion : elles appartiennent à un * compte, et l'écran suivant pourrait être celui d'un autre. */ -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@OptIn(ExperimentalCoroutinesApi::class) class CatalogViewModel( private val catalogRepository: CatalogRepository, session: StateFlow, @@ -83,11 +82,9 @@ class CatalogViewModel( // Contrairement à la recherche locale, qui filtre en mémoire à chaque // frappe, celle-ci part sur le réseau : sans délai de grâce, taper // « écho » lancerait quatre requêtes dont trois inutiles. - // `flatMapLatest` abandonne la précédente dès qu'une frappe arrive. _search .map { it.query.trim() } .distinctUntilChanged() - .debounce { if (it.isBlank()) 0L else SEARCH_DEBOUNCE_MS } .flatMapLatest { query -> searchFlow(query) } .onEach { outcome -> _search.update { outcome(it) } } .launchIn(viewModelScope) @@ -106,6 +103,15 @@ class CatalogViewModel( * * Renvoyer des transformations plutôt que des états complets évite d'écraser * la requête que l'utilisateur continue de taper pendant l'appel. + * + * Le délai de grâce est **dans** ce flux, et non avant `flatMapLatest` : + * placé avant, il retarderait aussi l'arrivée de la nouvelle requête, et la + * précédente continuerait de courir pendant ce temps — jusqu'à rendre des + * résultats périmés sous une requête déjà changée. Ici, une frappe annule + * immédiatement l'attente comme l'appel en cours. + * + * Une requête vide n'attend pas : effacer le champ doit vider l'écran tout + * de suite. */ private fun searchFlow(query: String): Flow<(RemoteSearchState) -> RemoteSearchState> = flow { if (query.isBlank()) { @@ -113,6 +119,7 @@ class CatalogViewModel( return@flow } + delay(SEARCH_DEBOUNCE_MS) emit { it.copy(isSearching = true, errorMessage = null) } try { val results = catalogRepository.search(query) diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt index e05ccf8..2bda27b 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.material3.Tab import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -230,10 +232,19 @@ private fun RemoteSearchResultsList( ) } - else -> LazyColumn( - contentPadding = PaddingValues(bottom = bottomPadding), - modifier = Modifier.fillMaxSize(), - ) { + else -> { + val listState = rememberLazyListState() + + // Une nouvelle requête donne une autre liste : rester au milieu de + // l'ancienne ferait manquer les premiers résultats, ceux que le + // serveur juge les plus pertinents. + LaunchedEffect(state.query.trim()) { listState.scrollToItem(0) } + + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { items(state.results.songs, key = { "song-${it.id}" }) { song -> MediaRow( artworkUri = song.artworkUri, @@ -259,6 +270,7 @@ private fun RemoteSearchResultsList( artworkShape = CircleShape, ) } + } } } } diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index ba1db15..1de247d 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -289,7 +289,9 @@ class PagingCatalogApi( searchGate?.await() searchFailure?.let { throw it } return RemoteSearchResults( - songs = searchResults.filter { it.title.contains(query, ignoreCase = true) }, + songs = searchResults + .filter { it.title.contains(query, ignoreCase = true) } + .page(offset, limit), ) } diff --git a/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt index 839570f..d179109 100644 --- a/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt @@ -108,11 +108,13 @@ class CatalogSearchTest { val viewModel = viewModel(catalog) viewModel.onSearchQueryChange("Écho") - advanceTimeBy(200) + advanceTimeBy(299) runCurrent() assertTrue("rien tant que la frappe peut continuer", catalog.searchQueries.isEmpty()) - advanceTimeBy(200) + // La milliseconde qui suit : le délai est épinglé à sa valeur, pas + // à un intervalle où n'importe quelle constante passerait. + advanceTimeBy(1) runCurrent() assertEquals(listOf("Écho"), catalog.searchQueries) } @@ -179,6 +181,48 @@ class CatalogSearchTest { assertEquals("Écho lo", viewModel.search.value.query) } + @Test + fun `une frappe annule la recherche en cours, resultats compris`() = + runTest(mainDispatcherRule.dispatcher) { + // Le délai est sous `flatMapLatest`, pas avant : placé avant, il + // retarderait la nouvelle requête et la précédente continuerait de + // courir, jusqu'à rendre ses résultats sous une requête changée. + val portail = CompletableDeferred() + val catalog = PagingCatalogApi(searchResults = catalogue, searchGate = portail) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceTimeBy(400) + runCurrent() + assertEquals("la première est partie", listOf("Écho"), catalog.searchQueries) + + // Elle est toujours en vol quand la suivante arrive, et se + // débloque juste après. + viewModel.onSearchQueryChange("Réso") + portail.complete(Unit) + + // Moins que le délai de grâce : la seconde recherche n'est pas + // encore partie. C'est l'instant qui départage les deux montages — + // le délai placé avant `flatMapLatest` laisserait la première + // rendre ici ses résultats, sous une requête déjà changée. Les deux + // convergent ensuite, ce que la fin du test ne prouverait donc pas. + advanceTimeBy(100) + runCurrent() + assertTrue( + "aucun résultat périmé sous la nouvelle requête", + viewModel.search.value.results.isEmpty, + ) + + advanceUntilIdle() + val state = viewModel.search.value + assertEquals("Réso", state.query) + assertEquals( + "les résultats sont ceux de la requête affichée", + listOf("Résonance"), + state.results.songs.map { it.title }, + ) + } + @Test fun `un echec devient un message et non des resultats vides`() = runTest(mainDispatcherRule.dispatcher) {