Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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)
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
},
Expand Down Expand Up @@ -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)
}
},
)
}
}
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/CatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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.
*
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/Dto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArtistResponse> = emptyList(),
val albums: List<AlbumResponse> = emptyList(),
val songs: List<SongResponse> = emptyList(),
)

/** `{"url": "/api/v2/stream/<ticket>", "expires_at": <ms>}` — l'URL est relative. */
@Serializable
internal data class StreamTicketResponse(
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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]. */
Expand Down Expand Up @@ -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<SearchResponse>().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,
Expand Down Expand Up @@ -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<app.waveflow.model.RemoteSong>(
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/app/waveflow/model/RemoteCatalog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,19 @@ data class RemoteArtistDetail(
val artist: RemoteArtist,
val albums: List<RemoteAlbum>,
)

/**
* 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<RemoteSong> = emptyList(),
val albums: List<RemoteAlbum> = emptyList(),
val artists: List<RemoteArtist> = emptyList(),
) {
val isEmpty: Boolean
get() = songs.isEmpty() && albums.isEmpty() && artists.isEmpty()
}
8 changes: 7 additions & 1 deletion app/src/main/java/app/waveflow/ui/search/SearchField.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion app/src/main/java/app/waveflow/ui/search/SearchScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,16 +45,28 @@ 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()) {
CenteredMessage(
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
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -32,6 +33,25 @@ data class DetailState<T>(
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<RemoteAlbumDetail>

typealias ArtistDetailState = DetailState<RemoteArtistDetail>
Loading