From 0b040cb8f7e83c00356cb973d641b7d128505c22 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 10 Aug 2026 22:09:25 +0200 Subject: [PATCH 1/3] feat(serveur): lire en streaming depuis le serveur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Troisième étape de la source distante : un morceau du catalogue serveur se lit, avec la file de son album, dans le même lecteur que les fichiers locaux. La diffusion passe par un ticket. `POST /tracks/{id}/stream-ticket` rend une URL qui ne demande aucun en-tête d'autorisation, ce qui permet de la confier telle quelle à ExoPlayer — requêtes de plage d'un déplacement comprises, ce qu'un jeton porté en en-tête aurait compliqué au sein d'une couche qui gère ses propres reconnexions. Le ticket est demandé au moment où le lecteur ouvre la piste, et non à la constitution de la file : il ne vit qu'une heure, et une longue file dépasserait l'échéance avant d'atteindre ses derniers morceaux. Un `ResolvingDataSource` échange le marqueur `waveflow://track/` contre l'URL réelle sur le fil de chargement. Les fichiers locaux traversent la même chaîne sans être touchés, ce qui garde un seul lecteur et une seule file. Le morceau courant cesse d'être un identifiant à résoudre dans la bibliothèque. `PlaybackState` porte désormais un `PlayingTrack` décrit par le lecteur lui-même : une piste du serveur n'existe pas dans le MediaStore, et l'y chercher ne rendait rien à afficher. `PlayerViewModel` n'a plus besoin du `LibraryStore`, et sa projection — réévaluée à chaque tic de position — n'a plus de recherche à faire. Le `mediaId` est préfixé par sa source. Sans ça, un identifiant distant purement numérique se convertirait en Long et passerait pour une piste locale. Validé contre un waveflow-server 2.0.0-beta.0 local : ticket obtenu, URL absolue résolue depuis le chemin relatif du serveur, lecture sans en-tête en audio/mpeg, requête de plage en 206, et deux tickets successifs bien distincts. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- README.md | 26 +++-- .../main/java/app/waveflow/MainActivity.kt | 12 +- .../app/waveflow/data/remote/CatalogApi.kt | 10 ++ .../waveflow/data/remote/CatalogRepository.kt | 10 ++ .../main/java/app/waveflow/data/remote/Dto.kt | 7 ++ .../waveflow/data/remote/HttpCatalogApi.kt | 16 +++ .../app/waveflow/data/remote/ServerHttp.kt | 12 ++ .../playback/Media3PlaybackController.kt | 13 ++- .../app/waveflow/playback/MediaItemMapper.kt | 70 ++++++++++-- .../waveflow/playback/PlaybackController.kt | 15 ++- .../app/waveflow/playback/PlaybackService.kt | 14 +++ .../app/waveflow/playback/PlayingTrack.kt | 30 +++++ .../waveflow/playback/RemoteStreamResolver.kt | 41 +++++++ .../app/waveflow/ui/components/MediaRow.kt | 6 +- .../java/app/waveflow/ui/player/MiniPlayer.kt | 11 +- .../waveflow/ui/player/NowPlayingScreen.kt | 33 +++--- .../app/waveflow/ui/player/PlayerUiState.kt | 9 +- .../app/waveflow/ui/player/PlayerViewModel.kt | 33 ++++-- .../ui/server/catalog/RemoteDetailScreens.kt | 25 ++++- .../waveflow/playback/MediaItemMapperTest.kt | 89 +++++++++++++++ .../playback/RemoteStreamResolverTest.kt | 104 ++++++++++++++++++ .../test/java/app/waveflow/testing/Fakes.kt | 25 +++++ .../java/app/waveflow/testing/ServerFakes.kt | 15 +++ .../waveflow/ui/player/PlayerViewModelTest.kt | 73 ++++++++---- 24 files changed, 618 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/app/waveflow/playback/PlayingTrack.kt create mode 100644 app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt create mode 100644 app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt create mode 100644 app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt diff --git a/README.md b/README.md index 5b692b5..739a1a6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ Native Android client for [WaveFlow](https://github.com/InstaZDLL/WaveFlow) — local-first music player. Kotlin + Jetpack Compose + Media3. > **Status:** local-first. Plays, browses, searches and organises the device's -> own files. A WaveFlow server can be signed into and its catalogue browsed; -> nothing streams from it yet — see [Server](#server). +> own files, and streams from a WaveFlow server — see [Server](#server). ## Stack @@ -47,7 +46,9 @@ app/src/main/java/app/waveflow/ │ ├─ PlaybackService.kt Media3 MediaSessionService (ExoPlayer) │ ├─ PlaybackController.kt Playback facade + PlaybackState │ ├─ Media3PlaybackController.kt MediaController connection → StateFlow -│ └─ MediaItemMapper.kt Song ↔ MediaItem +│ ├─ MediaItemMapper.kt Song / RemoteSong → MediaItem, and back +│ ├─ PlayingTrack.kt What the player holds, whatever its source +│ └─ RemoteStreamResolver.kt Marker URI → ticketed stream URL └─ ui/ ├─ theme/ Material 3 emerald theme ├─ DurationFormat.kt m:ss / h:mm:ss @@ -123,7 +124,7 @@ in-memory SQLite for Room, so the DAO is exercised without a device. |---|---| | `PlaylistDaoTest` | duplicate adds, `updatedAt` bumping, positions, `reorder` normalisation, `createWithSong` atomicity, cascade delete | | `LibraryStoreTest` | loading, read failures, single subscription, retry | -| `PlayerViewModelTest` | contextual play queue, current-song resolution, controller release | +| `PlayerViewModelTest` | contextual play queue, local vs remote queue, controller release | | `PlaylistsViewModelTest` | flow failures, write failures, resolution order, atomic creation, reorder rollback and staleness | | `DragStateTest` | drag arithmetic: target rank, visual offset, bounds, `moved` | | `PlaylistDetailScreenTest` | reorder accessibility actions, order restored after a failed write | @@ -138,6 +139,8 @@ 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 | +| `MediaItemMapperTest` | local vs remote track identity, unreachable marker URI | +| `RemoteStreamResolverTest` | ticket swap, local passthrough, DataSpec preserved | Fakes and the `Dispatchers.Main` rule live in `src/test/java/app/waveflow/testing/`. @@ -157,7 +160,7 @@ into `DragState` and tested there instead. - [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) -- [ ] Stream from the server +- [x] Stream from the server (ticketed URLs, seeking) - [ ] Server user-data sync (playlists, favorites, ratings) — see below - [ ] Android Auto (Media3 `MediaLibraryService`) @@ -165,9 +168,8 @@ into `DragState` and tested there instead. The **Server** tab signs in to a [WaveFlow Server](https://github.com/InstaZDLL/waveflow-server), keeps the session alive -and browses its catalogue — albums, artists, and what each contains. Playback -is not wired up yet, so tapping a remote track does nothing. Nothing of the -local library is sent anywhere. +and browses its catalogue — albums, artists, and what each contains. Tapping a +remote track plays it. Nothing of the local library is sent anywhere. The two sources stay separate by design: the tab is its own section rather than a filter over the existing screens, and `RemoteAlbum` / `RemoteArtist` / @@ -175,6 +177,14 @@ a filter over the existing screens, and `RemoteAlbum` / `RemoteArtist` / UUIDs rather than `MediaStore` integers, and nothing can currently say that a remote track is the same file as a local one. +Playback goes through a **stream ticket**: `POST /tracks/{id}/stream-ticket` +returns a URL that needs no `Authorization` header, which is what lets ExoPlayer +consume it directly — range requests for seeking included. The ticket is minted +when the player opens the track, not when the queue is built: it lives an hour, +and a long queue would outlast it before reaching its last tracks. A +`ResolvingDataSource` does the swap, so local files and remote tracks share one +player and one queue mechanism. + Listing endpoints return a bare array — no total, no cursor — so the end of a list is inferred from a page shorter than requested. Cover art is not shown: the v2 API exposes an `artwork_hash` but no endpoint serving the image; only diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 8ae7961..11d795d 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -142,8 +142,8 @@ private fun WaveFlowRoot() { searchViewModel.clear() } - val nowPlayingId = playerState.song?.id - val hasTrack = playerState.song != null + val nowPlayingId = playerState.track?.localSongId + val hasTrack = playerState.track != null // Les écritures de playlist qui échouent se signalent une fois, sans // remplacer le contenu de l'écran. @@ -443,6 +443,14 @@ private fun WaveFlowRoot() { RemoteAlbumDetailScreen( state = detail, + nowPlayingMediaId = playerState.track?.mediaId, + // La file de lecture est l'album affiché, comme + // pour un album local. + onSongClick = { song -> + detail.value?.songs?.let { + playerViewModel.playRemoteFrom(it, song) + } + }, onRetry = { catalogViewModel.openAlbum(albumId) }, bottomPadding = listBottomPadding, ) 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 71405f5..d89fe18 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -35,6 +35,16 @@ interface CatalogApi { /** `GET /api/v2/artists/{id}` : l'artiste et ses albums. */ suspend fun artist(serverUrl: String, accessToken: String, artistId: String): RemoteArtistDetail + + /** + * `POST /api/v2/tracks/{id}/stream-ticket`. + * + * Rend une URL de diffusion **absolue**, qui ne demande aucun en-tête + * d'autorisation — le serveur la rend relative, elle est résolue ici contre + * [serverUrl]. C'est ce qui permet de la confier telle quelle à ExoPlayer, + * y compris pour les requêtes de plage d'un déplacement dans le morceau. + */ + suspend fun streamTicket(serverUrl: String, accessToken: String, trackId: String): String } /** 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 7f5c930..1920e23 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -29,6 +29,16 @@ class CatalogRepository( suspend fun artist(artistId: String): RemoteArtistDetail = authorized { url, token -> api.artist(url, token, artistId) } + /** + * URL de diffusion d'une piste, valable une heure côté serveur. + * + * Demandée au moment de lire, et non à la constitution de la file : une + * longue file dépasserait l'échéance avant d'atteindre ses derniers + * morceaux. + */ + suspend fun streamUrl(trackId: String): String = + authorized { url, token -> api.streamTicket(url, token, trackId) } + /** * Exécute [call] avec un jeton valide, en réessayant une fois sur refus. * 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 0adf124..e6edb48 100644 --- a/app/src/main/java/app/waveflow/data/remote/Dto.kt +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -113,6 +113,13 @@ internal data class AlbumDetailResponse( get() = AlbumResponse(id, title, artist, artistId, year) } +/** `{"url": "/api/v2/stream/", "expires_at": }` — l'URL est relative. */ +@Serializable +internal data class StreamTicketResponse( + val url: String, + @SerialName("expires_at") val expiresAt: Long, +) + @Serializable internal data class ArtistDetailResponse( val id: String, 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 c743a2b..5c1a9e8 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -69,6 +69,21 @@ class HttpCatalogApi( ) } + override suspend fun streamTicket( + serverUrl: String, + accessToken: String, + trackId: String, + ): String { + val ticket = http.post( + serverUrl = serverUrl, + path = "$TRACKS/$trackId/stream-ticket", + body = "{}", + accessToken = accessToken, + ).decode() + + return http.absoluteUrl(serverUrl, ticket.url) + } + private fun pageQuery(offset: Int, limit: Int) = mapOf( "offset" to offset.toString(), "limit" to limit.toString(), @@ -83,6 +98,7 @@ class HttpCatalogApi( private companion object { const val ALBUMS = "api/v2/albums" const val ARTISTS = "api/v2/artists" + const val TRACKS = "api/v2/tracks" /** 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/data/remote/ServerHttp.kt b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt index 8a43d7b..2739fb7 100644 --- a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt +++ b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt @@ -133,6 +133,18 @@ class ServerHttp( .build() } + /** + * Résout une URL rendue par le serveur contre l'adresse de celui-ci. + * + * Le ticket de diffusion arrive en chemin relatif : c'est ici qu'il devient + * joignable, en réutilisant la même normalisation d'adresse que les appels. + */ + fun absoluteUrl(serverUrl: String, path: String): String { + val base = serverUrl.toApiUrl(path = "", pathSegment = null, query = emptyMap()) + return base.resolve(path)?.toString() + ?: throw ServerException.Unexpected("URL de diffusion invalide : $path") + } + private fun Response.toException(): ServerException { // Le serveur répond `{code, message}` sur ses erreurs métier, mais un // corps mal formé lui fait renvoyer du texte brut : lire le message diff --git a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt index e8a271c..8d4a3cf 100644 --- a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt @@ -8,6 +8,7 @@ import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionToken +import app.waveflow.model.RemoteSong import app.waveflow.model.Song import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope @@ -90,6 +91,16 @@ class Media3PlaybackController( ctrl.play() } + override fun playRemote(songs: List, startIndex: Int) { + val ctrl = controller ?: return + if (songs.isEmpty()) return + + ctrl.shuffleModeEnabled = false + ctrl.setMediaItems(songs.map { it.toMediaItem() }, startIndex.coerceIn(songs.indices), 0L) + ctrl.prepare() + ctrl.play() + } + override fun playShuffled(songs: List) { val ctrl = controller ?: return if (songs.isEmpty()) return @@ -145,7 +156,7 @@ class Media3PlaybackController( private fun syncFrom(player: Player) { _state.value = PlaybackState( isConnected = true, - currentSongId = player.currentMediaItem?.songId, + current = player.currentMediaItem?.toPlayingTrack(), isPlaying = player.isPlaying, positionMs = player.currentPosition.coerceAtLeast(0L), durationMs = player.duration.takeIf { it != C.TIME_UNSET }?.coerceAtLeast(0L) ?: 0L, diff --git a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt index f5efd43..07c2472 100644 --- a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt +++ b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt @@ -1,19 +1,22 @@ package app.waveflow.playback +import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import app.waveflow.model.RemoteSong import app.waveflow.model.Song /** - * Traduction [Song] <-> [MediaItem]. + * Traduction des morceaux vers [MediaItem], et retour. * - * Le `mediaId` porte l'identifiant du morceau : c'est le seul lien entre ce - * que joue Media3 et le modèle de l'application, ce qui permet de retrouver - * la piste courante sans garder de référence côté lecteur. + * Le `mediaId` est préfixé par sa source. C'est le seul lien entre ce que joue + * Media3 et le modèle de l'application ; le préfixe évite qu'un identifiant + * MediaStore et un UUID distant se confondent, et permet de reconnaître une + * piste locale sans consulter la bibliothèque. */ fun Song.toMediaItem(): MediaItem = MediaItem.Builder() - .setMediaId(id.toString()) + .setMediaId("$LOCAL_PREFIX$id") .setUri(uri) .setMediaMetadata( MediaMetadata.Builder() @@ -25,6 +28,57 @@ fun Song.toMediaItem(): MediaItem = ) .build() -/** Identifiant [Song] porté par ce [MediaItem], ou `null` s'il vient d'ailleurs. */ -val MediaItem.songId: Long? - get() = mediaId.toLongOrNull() +/** + * Piste distante, dont l'URI n'est **pas** joignable telle quelle. + * + * Le schéma `waveflow` est un marqueur : [RemoteStreamResolver] l'échange + * contre une URL de diffusion au moment où le lecteur ouvre la piste. Frapper + * le serveur ici, à la construction de la file, périmerait les tickets des + * derniers morceaux avant qu'on ne les atteigne. + */ +fun RemoteSong.toMediaItem(): MediaItem = + MediaItem.Builder() + .setMediaId("$REMOTE_PREFIX$id") + .setUri("$REMOTE_SCHEME://track/$id".toUri()) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .build(), + ) + .build() + +/** Ce que le lecteur donne à voir de sa piste courante. */ +fun MediaItem.toPlayingTrack(): PlayingTrack = PlayingTrack( + mediaId = mediaId, + title = mediaMetadata.title?.toString().orEmpty(), + artist = mediaMetadata.artist?.toString(), + album = mediaMetadata.albumTitle?.toString(), + artworkUri = mediaMetadata.artworkUri, + localSongId = localSongId, + source = if (mediaId.startsWith(REMOTE_PREFIX)) TrackSource.Remote else TrackSource.Local, +) + +/** + * Identité de cette piste distante dans la file de lecture. + * + * Permet à un écran de reconnaître la ligne en cours sans construire de + * [MediaItem] : c'est la même clé que [PlayingTrack.mediaId]. + */ +val RemoteSong.mediaId: String + get() = "$REMOTE_PREFIX$id" + +/** Identifiant MediaStore porté par ce [MediaItem], ou `null` s'il vient d'ailleurs. */ +val MediaItem.localSongId: Long? + get() = mediaId.removePrefix(LOCAL_PREFIX).takeIf { mediaId.startsWith(LOCAL_PREFIX) }?.toLongOrNull() + +/** Identifiant de piste serveur, ou `null` si la piste est locale. */ +internal fun trackIdOfRemoteUri(uri: android.net.Uri): String? = + uri.lastPathSegment?.takeIf { uri.scheme == REMOTE_SCHEME } + +private const val LOCAL_PREFIX = "local:" +private const val REMOTE_PREFIX = "remote:" + +/** Schéma interne : aucune pile réseau ne sait le résoudre, et c'est voulu. */ +internal const val REMOTE_SCHEME = "waveflow" diff --git a/app/src/main/java/app/waveflow/playback/PlaybackController.kt b/app/src/main/java/app/waveflow/playback/PlaybackController.kt index b34d628..07ae430 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackController.kt @@ -1,5 +1,6 @@ package app.waveflow.playback +import app.waveflow.model.RemoteSong import app.waveflow.model.Song import kotlinx.coroutines.flow.StateFlow @@ -20,7 +21,8 @@ enum class RepeatMode { * * @property isConnected `true` une fois la liaison au service établie ; tant * qu'il est `false`, les commandes sont ignorées. - * @property currentSongId identifiant du morceau courant, `null` si la file est vide. + * @property current morceau courant, `null` si la file est vide. Décrit par ce + * que le lecteur en sait : il peut venir de l'appareil comme d'un serveur. * @property isPlaying lecture réellement en cours (pas seulement demandée). * @property positionMs position de lecture en millisecondes. * @property durationMs durée du morceau courant, 0 si inconnue. @@ -29,7 +31,7 @@ enum class RepeatMode { */ data class PlaybackState( val isConnected: Boolean = false, - val currentSongId: Long? = null, + val current: PlayingTrack? = null, val isPlaying: Boolean = false, val positionMs: Long = 0L, val durationMs: Long = 0L, @@ -54,6 +56,15 @@ interface PlaybackController { /** Charge [songs] comme file d'attente et démarre à [startIndex]. */ fun play(songs: List, startIndex: Int) + /** + * Même chose pour des morceaux du serveur. + * + * File distincte plutôt que mêlée à la locale : les deux sources sont + * séparées partout ailleurs dans l'app, et rien ne permet de dire qu'une + * piste distante et une piste locale sont le même enregistrement. + */ + fun playRemote(songs: List, startIndex: Int) + /** * Charge [songs] en activant la lecture aléatoire et démarre sur un * morceau au hasard. diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index e444f40..14b5196 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -3,9 +3,13 @@ package app.waveflow.playback import android.content.Intent import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.ResolvingDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import app.waveflow.WaveFlowApp /** * Service de lecture porté par Media3. @@ -23,7 +27,17 @@ class PlaybackService : MediaSessionService() { override fun onCreate() { super.onCreate() + // Les pistes distantes portent un marqueur `waveflow://` que rien ne sait + // ouvrir : ce résolveur l'échange contre une URL de diffusion au moment + // où le lecteur en a besoin. Les fichiers locaux traversent la même + // chaîne sans être touchés. + val dataSourceFactory = ResolvingDataSource.Factory( + DefaultDataSource.Factory(this), + RemoteStreamResolver((application as WaveFlowApp).container.catalogRepository), + ) + val player = ExoPlayer.Builder(this) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) // Route audio "musique" + gestion du focus audio (pause si un appel // arrive, etc.). .setAudioAttributes( diff --git a/app/src/main/java/app/waveflow/playback/PlayingTrack.kt b/app/src/main/java/app/waveflow/playback/PlayingTrack.kt new file mode 100644 index 0000000..31633cd --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/PlayingTrack.kt @@ -0,0 +1,30 @@ +package app.waveflow.playback + +import android.net.Uri + +/** + * Le morceau que le lecteur a réellement en main. + * + * Décrit par ce que Media3 en sait, et non par un identifiant à résoudre dans + * la bibliothèque locale : depuis que le serveur peut alimenter la file, un + * morceau en cours n'est plus forcément un fichier de l'appareil. + * + * @property mediaId identité stable dans la file, quelle que soit la source. + * @property localSongId identifiant MediaStore quand la piste est locale, `null` + * sinon. Sert à souligner la ligne en cours dans les listes locales. + * @property source d'où vient la piste, à afficher quand l'album est inconnu. + */ +data class PlayingTrack( + val mediaId: String, + val title: String, + val artist: String?, + val album: String?, + val artworkUri: Uri?, + val localSongId: Long?, + val source: TrackSource, +) + +enum class TrackSource(val label: String) { + Local("Bibliothèque locale"), + Remote("Serveur WaveFlow"), +} diff --git a/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt b/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt new file mode 100644 index 0000000..0d3f3e5 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt @@ -0,0 +1,41 @@ +package app.waveflow.playback + +import androidx.core.net.toUri +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.ResolvingDataSource +import app.waveflow.data.remote.CatalogRepository +import kotlinx.coroutines.runBlocking +import java.io.IOException + +/** + * Échange le marqueur `waveflow://track/` contre une URL de diffusion. + * + * Media3 appelle ce résolveur juste avant d'ouvrir une piste, sur son fil de + * chargement. C'est exactement le moment voulu : le ticket obtenu ne vit qu'une + * heure, et le demander à la constitution de la file périmerait celui des + * derniers morceaux avant qu'on ne les atteigne. Un déplacement dans le morceau + * rouvre la source et en redemande un, ce qui règle aussi l'expiration en cours + * d'écoute. + * + * L'appel est bloquant parce que le contrat de [ResolvingDataSource.Resolver] + * l'est ; il s'exécute hors du fil principal, sur le fil de chargement. + */ +class RemoteStreamResolver( + private val catalogRepository: CatalogRepository, +) : ResolvingDataSource.Resolver { + + override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { + val trackId = trackIdOfRemoteUri(dataSpec.uri) ?: return dataSpec + + val url = try { + runBlocking { catalogRepository.streamUrl(trackId) } + } catch (error: Exception) { + // Media3 n'attend que des IOException ici : toute autre remonterait + // brute jusqu'au lecteur et ferait tomber le service au lieu de + // signaler une piste illisible. + throw IOException("Diffusion indisponible pour la piste $trackId", error) + } + + return dataSpec.withUri(url.toUri()) + } +} diff --git a/app/src/main/java/app/waveflow/ui/components/MediaRow.kt b/app/src/main/java/app/waveflow/ui/components/MediaRow.kt index 58cb216..63b5cab 100644 --- a/app/src/main/java/app/waveflow/ui/components/MediaRow.kt +++ b/app/src/main/java/app/waveflow/ui/components/MediaRow.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -27,6 +28,8 @@ import androidx.compose.ui.unit.dp * une destination. * * @param artworkShape ronde pour un artiste, arrondie pour un album. + * @param titleColor teintée pour signaler la ligne en cours de lecture, comme + * le fait [SongRow] dans les listes locales. * @param onClick `null` pour une ligne purement informative. Rendre le clic * facultatif plutôt que d'en passer un vide : un `Modifier.clickable` inerte * annonce quand même la ligne comme actionnable à TalkBack, et l'ondulation @@ -40,6 +43,7 @@ fun MediaRow( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, artworkShape: Shape = RoundedCornerShape(6.dp), + titleColor: Color = MaterialTheme.colorScheme.onSurface, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -58,7 +62,7 @@ fun MediaRow( Text( text = title, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = titleColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/java/app/waveflow/ui/player/MiniPlayer.kt b/app/src/main/java/app/waveflow/ui/player/MiniPlayer.kt index 0021627..d1f1835 100644 --- a/app/src/main/java/app/waveflow/ui/player/MiniPlayer.kt +++ b/app/src/main/java/app/waveflow/ui/player/MiniPlayer.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.lerp import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.waveflow.model.orUnknownArtist import app.waveflow.ui.components.Artwork /** @@ -43,8 +44,8 @@ fun MiniPlayer( onSkipNext: () -> Unit, modifier: Modifier = Modifier, ) { - val song = state.song ?: return - val accent = rememberArtworkAccent(song.artworkUri) + val track = state.track ?: return + val accent = rememberArtworkAccent(track.artworkUri) Surface( shape = RoundedCornerShape(12.dp), @@ -63,20 +64,20 @@ fun MiniPlayer( .padding(horizontal = 10.dp, vertical = 8.dp), ) { Artwork( - artworkUri = song.artworkUri, + artworkUri = track.artworkUri, modifier = Modifier.size(44.dp), ) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = song.title, + text = track.title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - text = song.displayArtist, + text = track.artist.orUnknownArtist(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt index e63c1c1..3bacb5b 100644 --- a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt @@ -48,7 +48,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.waveflow.model.Song +import app.waveflow.model.orUnknownArtist +import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode import app.waveflow.ui.components.Artwork import app.waveflow.ui.formatDuration @@ -75,13 +76,13 @@ fun NowPlayingScreen( // La file peut se vider pendant l'animation de fermeture : on continue // d'afficher le dernier morceau connu le temps que l'écran redescende, // plutôt que de le faire disparaître d'un coup. - var lastKnownSong by remember { mutableStateOf(state.song) } - LaunchedEffect(state.song) { - state.song?.let { lastKnownSong = it } + var lastKnownTrack by remember { mutableStateOf(state.track) } + LaunchedEffect(state.track) { + state.track?.let { lastKnownTrack = it } } - val song = state.song ?: lastKnownSong ?: return - val accent = rememberArtworkAccent(song.artworkUri) + val track = state.track ?: lastKnownTrack ?: return + val accent = rememberArtworkAccent(track.artworkUri) Box( modifier = modifier @@ -97,12 +98,12 @@ fun NowPlayingScreen( .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = 24.dp), ) { - PlayerHeader(song = song, onCollapse = onCollapse) + PlayerHeader(track = track, onCollapse = onCollapse) Spacer(Modifier.weight(1f)) Artwork( - artworkUri = song.artworkUri, + artworkUri = track.artworkUri, shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() @@ -112,14 +113,14 @@ fun NowPlayingScreen( Spacer(Modifier.weight(1f)) - TrackTitle(song = song) + TrackTitle(track = track) Spacer(Modifier.height(16.dp)) SeekBar( positionMs = state.positionMs, durationMs = state.durationMs, - trackKey = song.id, + trackKey = track.mediaId, onSeek = onSeek, ) @@ -142,7 +143,7 @@ fun NowPlayingScreen( } @Composable -private fun PlayerHeader(song: Song, onCollapse: () -> Unit) { +private fun PlayerHeader(track: PlayingTrack, onCollapse: () -> Unit) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -165,7 +166,7 @@ private fun PlayerHeader(song: Song, onCollapse: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = song.album ?: "Bibliothèque locale", + text = track.album ?: track.source.label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -179,10 +180,10 @@ private fun PlayerHeader(song: Song, onCollapse: () -> Unit) { } @Composable -private fun TrackTitle(song: Song) { +private fun TrackTitle(track: PlayingTrack) { Column(modifier = Modifier.fillMaxWidth()) { Text( - text = song.title, + text = track.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -191,7 +192,7 @@ private fun TrackTitle(song: Song) { ) Spacer(Modifier.height(4.dp)) Text( - text = song.displayArtist, + text = track.artist.orUnknownArtist(), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -204,7 +205,7 @@ private fun TrackTitle(song: Song) { private fun SeekBar( positionMs: Long, durationMs: Long, - trackKey: Long, + trackKey: String, onSeek: (Long) -> Unit, ) { // Pendant un glissement, la position affichée suit le doigt et non le diff --git a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt index fccd7ef..edea798 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt @@ -1,16 +1,17 @@ package app.waveflow.ui.player -import app.waveflow.model.Song +import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode /** * État du lecteur, partagé par le mini-player et l'écran plein écran. * - * @property song morceau courant, `null` quand rien n'est chargé — dans ce cas - * le lecteur ne s'affiche pas du tout. + * @property track morceau courant, `null` quand rien n'est chargé — dans ce cas + * le lecteur ne s'affiche pas du tout. Décrit par le lecteur lui-même et non + * résolu dans la bibliothèque : il peut venir d'un serveur. */ data class PlayerUiState( - val song: Song? = null, + val track: PlayingTrack? = null, val isPlaying: Boolean = false, val positionMs: Long = 0L, val durationMs: Long = 0L, diff --git a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt index c97b3a1..d04c944 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -6,12 +6,12 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import app.waveflow.WaveFlowApp -import app.waveflow.data.LibraryStore +import app.waveflow.model.RemoteSong import app.waveflow.model.Song import app.waveflow.playback.PlaybackController import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn /** @@ -21,16 +21,17 @@ import kotlinx.coroutines.flow.stateIn * vivante empêcherait le service de s'arrêter. */ class PlayerViewModel( - libraryStore: LibraryStore, private val playbackController: PlaybackController, ) : ViewModel() { + // Plus de croisement avec la bibliothèque : le lecteur décrit lui-même sa + // piste, ce qui vaut aussi pour celles du serveur, absentes du MediaStore. + // Cette projection est réévaluée à chaque tic de position — la garder sans + // recherche est ce qui la rend gratuite. val state: StateFlow = - combine(libraryStore.library, playbackController.state) { library, playback -> + playbackController.state.map { playback -> PlayerUiState( - // Index plutôt que parcours : cette combinaison est réévaluée à - // chaque tic de position. - song = playback.currentSongId?.let { library.songsById[it] }, + track = playback.current, isPlaying = playback.isPlaying, positionMs = playback.positionMs, durationMs = playback.durationMs, @@ -63,6 +64,23 @@ class PlayerViewModel( fun playShuffled(queue: List) = playbackController.playShuffled(queue) + /** + * Démarre [song] depuis une file de morceaux du serveur. + * + * Chemin distinct de [playFrom] : les deux catalogues ne partagent ni type + * ni identifiant, et la file remplace l'autre plutôt que de s'y mêler. + */ + fun playRemoteFrom(queue: List, song: RemoteSong) { + val startIndex = queue.indexOfFirst { it.id == song.id } + if (startIndex < 0) return + playbackController.playRemote(queue, startIndex) + } + + /** Démarre [queue] distante par son premier morceau. */ + fun playRemoteFirst(queue: List) { + queue.firstOrNull()?.let { playRemoteFrom(queue, it) } + } + fun togglePlayPause() = playbackController.playPause() fun skipNext() = playbackController.skipNext() @@ -88,7 +106,6 @@ class PlayerViewModel( initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as WaveFlowApp PlayerViewModel( - libraryStore = app.container.libraryStore, playbackController = app.container.createPlaybackController(), ) } diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt index 587d709..48aeb74 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,6 +18,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteSong +import app.waveflow.playback.mediaId import app.waveflow.model.orUnknownArtist import app.waveflow.ui.albumCountLabel import app.waveflow.ui.components.CenteredMessage @@ -28,6 +30,8 @@ import app.waveflow.ui.trackCountLabel @Composable fun RemoteAlbumDetailScreen( state: AlbumDetailState, + nowPlayingMediaId: String?, + onSongClick: (RemoteSong) -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp, @@ -49,7 +53,11 @@ fun RemoteAlbumDetailScreen( } items(detail.songs, key = { it.id }) { song -> - RemoteSongRow(song = song) + RemoteSongRow( + song = song, + isCurrent = song.mediaId == nowPlayingMediaId, + onClick = { onSongClick(song) }, + ) } } } @@ -147,7 +155,11 @@ private fun RemoteDetailHeader( } @Composable -private fun RemoteSongRow(song: RemoteSong) { +private fun RemoteSongRow( + song: RemoteSong, + isCurrent: Boolean, + onClick: () -> Unit, +) { MediaRow( artworkUri = null, title = song.title, @@ -155,8 +167,11 @@ private fun RemoteSongRow(song: RemoteSong) { song.artist?.takeIf { it.isNotBlank() }, formatDuration(song.durationMs), ).joinToString(" · "), - // Sans `onClick` : la lecture distante arrive à l'étape suivante, et une - // ligne annoncée cliquable qui ne fait rien vaut moins qu'une ligne - // simplement informative. + onClick = onClick, + titleColor = if (isCurrent) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, ) } diff --git a/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt b/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt new file mode 100644 index 0000000..11f086b --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt @@ -0,0 +1,89 @@ +package app.waveflow.playback + +import androidx.core.net.toUri +import app.waveflow.testing.remoteSong +import app.waveflow.testing.song +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * La traduction vers Media3, et surtout la distinction des sources. + * + * Robolectric pour disposer d'un vrai `android.net.Uri`. + */ +@RunWith(RobolectricTestRunner::class) +class MediaItemMapperTest { + + @Test + fun `une piste locale garde son identifiant MediaStore`() { + val item = song(id = 42L, title = "Ciel Bas").toMediaItem() + + assertEquals(42L, item.localSongId) + assertEquals(TrackSource.Local, item.toPlayingTrack().source) + assertEquals("Ciel Bas", item.toPlayingTrack().title) + } + + @Test + fun `une piste distante n'est pas prise pour une piste locale`() { + // Sans préfixe de source, un UUID rendrait `null` à la conversion en + // Long — indiscernable d'une piste locale non résolue. + val item = remoteSong(id = "c07f8d98").toMediaItem() + + assertNull(item.localSongId) + assertEquals(TrackSource.Remote, item.toPlayingTrack().source) + } + + @Test + fun `un identifiant distant purement numerique reste distant`() { + // Le cas qui piège : `"1234"` se convertit en Long sans erreur. + val item = remoteSong(id = "1234").toMediaItem() + + assertNull(item.localSongId) + assertEquals(TrackSource.Remote, item.toPlayingTrack().source) + } + + @Test + fun `l'URI d'une piste distante n'est pas joignable telle quelle`() { + // C'est un marqueur : le frapper au moment de bâtir la file périmerait + // les tickets des derniers morceaux avant qu'on ne les atteigne. + val item = remoteSong(id = "c07f8d98").toMediaItem() + + val uri = item.localConfiguration?.uri + assertEquals("waveflow", uri?.scheme) + assertEquals("c07f8d98", uri?.let { trackIdOfRemoteUri(it) }) + } + + @Test + fun `une URI locale n'est pas prise pour une piste a resoudre`() { + val uri = "content://media/external/audio/media/42".toUri() + + assertNull(trackIdOfRemoteUri(uri)) + } + + @Test + fun `l'identite d'une piste distante est la meme des deux cotes`() { + // L'écran s'en sert pour souligner la ligne en cours sans construire de + // MediaItem : les deux doivent coïncider. + val remote = remoteSong(id = "c07f8d98") + + assertEquals(remote.mediaId, remote.toMediaItem().toPlayingTrack().mediaId) + } + + @Test + fun `les metadonnees d'une piste distante suivent jusqu'au lecteur`() { + val track = remoteSong(id = "a", title = "Résonance", artist = "Bruit de Fond", album = "Écho") + .toMediaItem() + .toPlayingTrack() + + assertEquals("Résonance", track.title) + assertEquals("Bruit de Fond", track.artist) + assertEquals("Écho", track.album) + // Le catalogue v2 n'expose aucun point d'accès aux pochettes. + assertNull(track.artworkUri) + assertTrue(track.source == TrackSource.Remote) + } +} diff --git a/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt b/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt new file mode 100644 index 0000000..dec0700 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt @@ -0,0 +1,104 @@ +package app.waveflow.playback + +import androidx.core.net.toUri +import androidx.media3.datasource.DataSpec +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.FakeCatalogApi +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.IOException + +/** + * L'échange du marqueur contre une URL de diffusion. + * + * Media3 appelle ce résolveur de façon bloquante, sur son fil de chargement : + * les tests l'appellent donc directement, sans coroutine. + */ +@RunWith(RobolectricTestRunner::class) +class RemoteStreamResolverTest { + + private val session = ServerSession.Connected( + serverUrl = "https://musique.test", + username = "admin", + accessToken = "wfa_1", + refreshToken = "wfr_1", + deviceId = "appareil-1", + accessExpiresAtMs = Long.MAX_VALUE, + ) + + private suspend fun resolver(catalog: FakeCatalogApi): RemoteStreamResolver { + val sessions = ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(stored = session), + deviceName = "Pixel de test", + now = { 0L }, + ) + sessions.restore() + return RemoteStreamResolver(CatalogRepository(catalog, sessions)) + } + + private fun specOf(uri: String) = DataSpec(uri.toUri()) + + @Test + fun `un marqueur distant devient une URL de diffusion`() = runTest { + val catalog = FakeCatalogApi() + val resolved = resolver(catalog).resolveDataSpec(specOf("waveflow://track/c07f8d98")) + + assertEquals( + "https://musique.test/api/v2/stream/ticket-c07f8d98", + resolved.uri.toString(), + ) + } + + @Test + fun `une piste locale traverse le resolveur sans etre touchee`() = runTest { + // Les fichiers de l'appareil passent par la même chaîne : leur laisser + // leur URI est ce qui permet de n'avoir qu'un lecteur. + val catalog = FakeCatalogApi() + val spec = specOf("content://media/external/audio/media/42") + + val resolved = resolver(catalog).resolveDataSpec(spec) + + assertEquals(spec.uri, resolved.uri) + assertEquals("aucun appel au serveur", 0, catalog.calls) + } + + @Test + fun `le reste du DataSpec est preserve`() = runTest { + // La position et la longueur portent la reprise après un déplacement : + // les perdre relancerait le morceau depuis le début. + val spec = DataSpec.Builder() + .setUri("waveflow://track/abc".toUri()) + .setPosition(4_096L) + .setLength(1_024L) + .build() + + val resolved = resolver(FakeCatalogApi()).resolveDataSpec(spec) + + assertEquals(4_096L, resolved.position) + assertEquals(1_024L, resolved.length) + } + + @Test + fun `un echec du serveur devient une IOException`() = runTest { + // Media3 n'attend que ça ici : toute autre exception remonterait brute + // jusqu'au lecteur et ferait tomber le service. + val catalog = FakeCatalogApi(failure = ServerException.Unreachable("coupure")) + + val error = runCatching { + resolver(catalog).resolveDataSpec(specOf("waveflow://track/abc")) + }.exceptionOrNull() + + assertTrue(error.toString(), error is IOException) + assertTrue(error?.cause is ServerException.Unreachable) + } +} diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index 0757ad8..d233adb 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -5,6 +5,7 @@ import app.waveflow.data.MusicRepository import app.waveflow.data.PlaylistRepository import app.waveflow.model.Playlist import app.waveflow.model.PlaylistEntry +import app.waveflow.model.RemoteSong import app.waveflow.model.Song import app.waveflow.playback.PlaybackController import app.waveflow.playback.PlaybackState @@ -41,6 +42,25 @@ fun song( artworkUri = null, ) +/** Fabrique de morceaux distants pour les tests. */ +fun remoteSong( + id: String, + title: String = "Titre $id", + artist: String? = "Artiste $id", + album: String? = "Album $id", + albumId: String? = "album-$id", + trackNumber: Int? = 1, + durationMs: Long = 60_000L, +): RemoteSong = RemoteSong( + id = id, + title = title, + album = album, + albumId = albumId, + artist = artist, + trackNumber = trackNumber, + durationMs = durationMs, +) + class FakeMusicRepository( private val songs: Flow> = flowOf(emptyList()), ) : MusicRepository { @@ -120,6 +140,7 @@ class FakePlaybackController : PlaybackController { var released = false private set val playCalls = mutableListOf, Int>>() + val playRemoteCalls = mutableListOf, Int>>() val playShuffledCalls = mutableListOf>() override fun connect() { @@ -131,6 +152,10 @@ class FakePlaybackController : PlaybackController { playCalls += songs to startIndex } + override fun playRemote(songs: List, startIndex: Int) { + playRemoteCalls += songs to startIndex + } + override fun playShuffled(songs: List) { playShuffledCalls += songs } diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 592d545..dbde707 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -151,6 +151,15 @@ class FakeCatalogApi( ) } + override suspend fun streamTicket( + serverUrl: String, + accessToken: String, + trackId: String, + ): String { + record(serverUrl, accessToken, null) + return "$serverUrl/api/v2/stream/ticket-$trackId" + } + private fun record(serverUrl: String, accessToken: String, page: Pair?) { calls++ lastServerUrl = serverUrl @@ -249,6 +258,12 @@ class PagingCatalogApi( ) } + override suspend fun streamTicket( + serverUrl: String, + accessToken: String, + trackId: String, + ): String = "$serverUrl/api/v2/stream/ticket-$trackId" + private fun failIfDue(call: Int) { if (failFromCall > 0 && call >= failFromCall) { throw ServerException.Unreachable("coupure") diff --git a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt index 7334e75..dd459b9 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt @@ -4,20 +4,20 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import app.waveflow.data.LibraryStore import app.waveflow.playback.PlaybackState -import app.waveflow.testing.FakeMusicRepository +import app.waveflow.playback.PlayingTrack +import app.waveflow.playback.TrackSource import app.waveflow.testing.FakePlaybackController import app.waveflow.testing.MainDispatcherRule +import app.waveflow.testing.remoteSong import app.waveflow.testing.song -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -32,15 +32,12 @@ class PlayerViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val songs = listOf(song(id = 1L), song(id = 2L), song(id = 3L)) + private val remoteSongs = listOf(remoteSong("a"), remoteSong("b"), remoteSong("c")) private val controller = FakePlaybackController() - private fun CoroutineScope.loadedStore(): LibraryStore = - LibraryStore(FakeMusicRepository(flowOf(songs)), this).also { it.load() } - @Test fun `jouer un morceau met la file demandee et non toute la bibliotheque`() = runTest { - val viewModel = PlayerViewModel(backgroundScope.loadedStore(), controller) - advanceUntilIdle() + val viewModel = PlayerViewModel(controller) val albumQueue = songs.take(2) viewModel.playFrom(albumQueue, songs[1]) @@ -50,8 +47,7 @@ class PlayerViewModelTest { @Test fun `jouer un morceau absent de la file ne declenche rien`() = runTest { - val viewModel = PlayerViewModel(backgroundScope.loadedStore(), controller) - advanceUntilIdle() + val viewModel = PlayerViewModel(controller) viewModel.playFrom(songs.take(2), song(id = 99L)) @@ -60,8 +56,7 @@ class PlayerViewModelTest { @Test fun `playFirst sur une file vide ne declenche rien`() = runTest { - val viewModel = PlayerViewModel(backgroundScope.loadedStore(), controller) - advanceUntilIdle() + val viewModel = PlayerViewModel(controller) viewModel.playFirst(emptyList()) @@ -69,20 +64,57 @@ class PlayerViewModelTest { } @Test - fun `l'etat resout le morceau courant depuis la bibliotheque`() = runTest { - val store = backgroundScope.loadedStore() - val viewModel = PlayerViewModel(store, controller) - advanceUntilIdle() + fun `jouer un morceau distant passe par la file distante`() = runTest { + // Chemin distinct : les deux catalogues ne partagent ni type ni + // identifiant, et la file distante remplace la locale. + val viewModel = PlayerViewModel(controller) + + viewModel.playRemoteFrom(remoteSongs, remoteSongs[2]) + + assertEquals(listOf(remoteSongs to 2), controller.playRemoteCalls) + assertTrue("la file locale ne doit pas être touchée", controller.playCalls.isEmpty()) + } + + @Test + fun `jouer un morceau distant absent de la file ne declenche rien`() = runTest { + val viewModel = PlayerViewModel(controller) + + viewModel.playRemoteFrom(remoteSongs, remoteSong("inconnu")) + + assertTrue(controller.playRemoteCalls.isEmpty()) + } + + @Test + fun `l'etat reprend la piste telle que le lecteur la decrit`() = runTest { + // Plus de résolution dans la bibliothèque : une piste du serveur n'y + // figure pas, et la chercher ne rendrait rien à afficher. + val viewModel = PlayerViewModel(controller) val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } controller.emit( - PlaybackState(isConnected = true, currentSongId = 2L, isPlaying = true, durationMs = 60_000L), + PlaybackState( + isConnected = true, + current = PlayingTrack( + mediaId = "remote:a", + title = "Résonance", + artist = "Bruit de Fond", + album = "Écho", + artworkUri = null, + localSongId = null, + source = TrackSource.Remote, + ), + isPlaying = true, + durationMs = 60_000L, + ), ) val state = viewModel.state.value - assertEquals(2L, state.song?.id) + assertEquals("Résonance", state.track?.title) + assertEquals(TrackSource.Remote, state.track?.source) + // Rien à souligner dans les listes locales pour une piste distante. + assertNull(state.track?.localSongId) assertEquals(true, state.isPlaying) job.cancel() @@ -90,13 +122,12 @@ class PlayerViewModelTest { @Test fun `le controleur est libere avec le ViewModel`() = runTest { - val store = backgroundScope.loadedStore() // On passe par un vrai ViewModelStore pour déclencher onCleared comme // le ferait la destruction de l'écran. val viewModelStore = ViewModelStore() val provider = ViewModelProvider( viewModelStore, - viewModelFactory { initializer { PlayerViewModel(store, controller) } }, + viewModelFactory { initializer { PlayerViewModel(controller) } }, ) provider[PlayerViewModel::class.java] advanceUntilIdle() From 9b7938c09886410b96e9ad8473de9927c1e7bb4d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 10 Aug 2026 23:15:05 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(serveur):=20pr=C3=A9server=20le=20pr?= =?UTF-8?q?=C3=A9fixe=20de=20proxy=20dans=20l'URL=20de=20diffusion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur rend le ticket sous la forme `/api/v2/stream/`, sans savoir qu'un proxy le préfixe. Résoudre ce chemin absolu contre l'adresse de base en écrasait le chemin : un serveur exposé sur `https://hôte/musique` voyait son préfixe disparaître, et l'URL de lecture n'atteignait plus rien. Le chemin est désormais traité comme n'importe quel chemin d'API, par la même construction que les appels — celle qui conserve déjà le préfixe. Seul un chemin absolu du serveur est accepté au passage. Une URL complète ou une référence réseau `//hôte/…` enverrait la lecture ailleurs que sur le serveur où l'utilisateur s'est authentifié ; le serveur n'en produit pas, autant le refuser franchement. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- .../app/waveflow/data/remote/ServerHttp.kt | 23 +++++++--- .../data/remote/HttpCatalogApiTest.kt | 44 +++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt index 2739fb7..2d60095 100644 --- a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt +++ b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt @@ -134,15 +134,26 @@ class ServerHttp( } /** - * Résout une URL rendue par le serveur contre l'adresse de celui-ci. + * Résout un chemin rendu par le serveur contre l'adresse de celui-ci. * - * Le ticket de diffusion arrive en chemin relatif : c'est ici qu'il devient - * joignable, en réutilisant la même normalisation d'adresse que les appels. + * Le ticket de diffusion arrive sous la forme `/api/v2/stream/`. + * Le passer à `resolve` écraserait le chemin de base : un serveur derrière + * un proxy qui le préfixe verrait son préfixe disparaître. Il est donc + * traité comme n'importe quel chemin d'API, par la même construction que + * les appels — qui, elle, conserve le préfixe. + * + * Seul un chemin absolu du serveur est accepté. Une URL complète ou une + * référence réseau (`//hôte/…`) désignerait un autre hôte que celui où + * l'utilisateur s'est authentifié. */ fun absoluteUrl(serverUrl: String, path: String): String { - val base = serverUrl.toApiUrl(path = "", pathSegment = null, query = emptyMap()) - return base.resolve(path)?.toString() - ?: throw ServerException.Unexpected("URL de diffusion invalide : $path") + if (!path.startsWith("/") || path.startsWith("//")) { + throw ServerException.Unexpected("Chemin de diffusion inattendu : $path") + } + + return serverUrl + .toApiUrl(path = path.removePrefix("/"), pathSegment = null, query = emptyMap()) + .toString() } private fun Response.toException(): ServerException { diff --git a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt index 0725186..893dc2f 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -122,6 +122,45 @@ class HttpCatalogApiTest { ) } + @Test + fun `le ticket de diffusion devient une URL absolue`() = runTest { + server.enqueue(MockResponse().setBody(TICKET_BODY)) + + val streamUrl = api.streamTicket(url(), "wfa_1", "c07f8d98") + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/v2/tracks/c07f8d98/stream-ticket", request.path) + assertEquals("Bearer wfa_1", request.getHeader("Authorization")) + + assertEquals("${url()}/api/v2/stream/VkdLrczM", streamUrl) + } + + @Test + fun `le prefixe de proxy est conserve dans l'URL de diffusion`() = runTest { + // Le serveur rend `/api/v2/stream/…` sans savoir qu'un proxy le préfixe. + // Résoudre ce chemin contre la racine effacerait le préfixe, et l'URL + // n'atteindrait plus le serveur. + server.enqueue(MockResponse().setBody(TICKET_BODY)) + + val streamUrl = api.streamTicket("${url()}/musique", "wfa_1", "c07f8d98") + + assertEquals("${url()}/musique/api/v2/stream/VkdLrczM", streamUrl) + } + + @Test + fun `un ticket qui designe un autre hote est refuse`() = runTest { + // Une URL complète ou une référence réseau enverrait la lecture ailleurs + // que sur le serveur où l'utilisateur s'est authentifié. + server.enqueue( + MockResponse().setBody("""{"url":"//ailleurs.test/api/v2/stream/x","expires_at":0}"""), + ) + + val error = echecDe { api.streamTicket(url(), "wfa_1", "c07f8d98") } + + assertTrue(error.toString(), error is ServerException.Unexpected) + } + @Test fun `un jeton refuse remonte comme tel`() = runTest { server.enqueue( @@ -253,6 +292,11 @@ class HttpCatalogApiTest { } """.trimIndent() + /** L'URL est relative au serveur, c'est ce que rend `stream-ticket`. */ + val TICKET_BODY = """ + {"url": "/api/v2/stream/VkdLrczM", "expires_at": 1786395364096} + """.trimIndent() + val ARTIST_DETAIL_BODY = """ { "id": "f7ba66f7-dfae-4e86-b1e1-f356f4c092b7", From 83ef16719665b788c483223242602548ca85791e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 11 Aug 2026 06:44:46 +0200 Subject: [PATCH 3/3] =?UTF-8?q?test(serveur):=20couvrir=20le=20refus=20d'u?= =?UTF-8?q?n=20ticket=20en=20URL=20compl=C3=A8te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La garde refusait déjà tout ce qui ne commence pas par `/`, mais seule la référence réseau `//hôte/…` était vérifiée. Le cas de l'URL complète passait par une branche non couverte : retirer la condition `!path.startsWith("/")` ne faisait tomber aucun test. Les deux cas sont maintenant distingués, et le second tombe bien sans la garde. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- .../data/remote/HttpCatalogApiTest.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt index 893dc2f..33d226a 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -149,9 +149,10 @@ class HttpCatalogApiTest { } @Test - fun `un ticket qui designe un autre hote est refuse`() = runTest { - // Une URL complète ou une référence réseau enverrait la lecture ailleurs - // que sur le serveur où l'utilisateur s'est authentifié. + fun `un ticket en reference reseau est refuse`() = runTest { + // `//hôte/…` emprunte le schéma courant et change d'hôte sans en avoir + // l'air : la lecture partirait ailleurs que sur le serveur où + // l'utilisateur s'est authentifié. server.enqueue( MockResponse().setBody("""{"url":"//ailleurs.test/api/v2/stream/x","expires_at":0}"""), ) @@ -161,6 +162,21 @@ class HttpCatalogApiTest { assertTrue(error.toString(), error is ServerException.Unexpected) } + @Test + fun `un ticket en URL complete est refuse`() = runTest { + // Même conclusion par un autre chemin : le contrat est un chemin du + // serveur, pas une URL qu'il choisirait librement. + server.enqueue( + MockResponse().setBody( + """{"url":"https://ailleurs.test/api/v2/stream/x","expires_at":0}""", + ), + ) + + val error = echecDe { api.streamTicket(url(), "wfa_1", "c07f8d98") } + + assertTrue(error.toString(), error is ServerException.Unexpected) + } + @Test fun `un jeton refuse remonte comme tel`() = runTest { server.enqueue(