From 0e3ddccb58463d85c2757a48b0bc95b5f2d1809b Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 10:08:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(lecture):=20choisir=20la=20qualit?= =?UTF-8?q?=C3=A9=20des=20pistes=20du=20serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois profils sous Réglages ▸ Serveur : qualité d'origine, haute qualité (Opus 160 kbit/s) et économie (Opus 96 kbit/s). L'original reste le défaut, et garde le marqueur et la clé de cache d'avant : qui ne touche à rien n'entend rien de différent et retrouve son cache. Le rendu voyage dans la piste et non dans les préférences. Le cache calcule sa clé avant que le résolveur ne construise l'URL ; lire le réglage à chaque bout laisserait un changement s'intercaler, et une version Opus se rangerait sous le nom de l'original. `withRendering` réécrit donc ensemble le marqueur et la clé, et le résolveur relit le marqueur. C'est le service qui pose ce rendu, dans `onAddMediaItems`, comme il applique déjà la vitesse : une file arrive aussi d'Android Auto, écran fermé. Le réglage y est lu une fois par file, et attendu au démarrage à froid plutôt que remplacé par le défaut. Un serveur sans ffmpeg grise les profils transcodés ; un relevé en échec ne ferme rien et garde ce qu'on savait. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- .../main/java/app/waveflow/MainActivity.kt | 16 +- .../app/waveflow/data/PreferencesStore.kt | 20 +++ .../app/waveflow/data/remote/CatalogApi.kt | 20 ++- .../waveflow/data/remote/CatalogRepository.kt | 9 +- .../main/java/app/waveflow/data/remote/Dto.kt | 12 ++ .../waveflow/data/remote/HttpCatalogApi.kt | 23 ++- .../app/waveflow/data/remote/ServerHttp.kt | 10 +- .../java/app/waveflow/model/AppPreferences.kt | 3 + .../java/app/waveflow/model/StreamQuality.kt | 62 +++++++ .../app/waveflow/playback/BrowseCallback.kt | 42 ++++- .../app/waveflow/playback/MediaItemMapper.kt | 47 +++++- .../app/waveflow/playback/PlaybackService.kt | 55 +++++- .../waveflow/playback/RemoteStreamResolver.kt | 8 +- .../ui/quality/StreamQualitySection.kt | 133 +++++++++++++++ .../ui/quality/StreamQualityViewModel.kt | 103 ++++++++++++ .../app/waveflow/ui/server/ServerScreen.kt | 17 +- .../app/waveflow/data/PreferencesStoreTest.kt | 43 +++++ .../data/remote/HttpCatalogApiTest.kt | 34 +++- .../waveflow/playback/BrowseCallbackTest.kt | 91 ++++++++++ .../waveflow/playback/MediaItemMapperTest.kt | 52 ++++++ .../playback/PlaybackServiceQualityTest.kt | 158 ++++++++++++++++++ .../playback/RemoteStreamResolverTest.kt | 21 +++ .../test/java/app/waveflow/testing/Fakes.kt | 6 + .../java/app/waveflow/testing/ServerFakes.kt | 18 ++ .../ui/quality/StreamQualitySectionTest.kt | 60 +++++++ .../ui/quality/StreamQualityViewModelTest.kt | 123 ++++++++++++++ .../waveflow/ui/server/ServerScreenTest.kt | 32 +++- 27 files changed, 1190 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/app/waveflow/model/StreamQuality.kt create mode 100644 app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt create mode 100644 app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt create mode 100644 app/src/test/java/app/waveflow/playback/BrowseCallbackTest.kt create mode 100644 app/src/test/java/app/waveflow/playback/PlaybackServiceQualityTest.kt create mode 100644 app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt create mode 100644 app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 2e6e698..ecc8f8f 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -73,6 +73,7 @@ import app.waveflow.ui.playlists.PlaylistDetailScreen import app.waveflow.ui.playlists.PlaylistMenu import app.waveflow.ui.playlists.PlaylistsScreen import app.waveflow.ui.playlists.PlaylistsViewModel +import app.waveflow.ui.quality.StreamQualityViewModel import app.waveflow.ui.search.SearchField import app.waveflow.ui.search.SearchScreen import app.waveflow.ui.search.SearchViewModel @@ -134,6 +135,7 @@ private fun WaveFlowRoot( val serverViewModel: ServerViewModel = viewModel(factory = ServerViewModel.Factory) val catalogViewModel: CatalogViewModel = viewModel(factory = CatalogViewModel.Factory) val cacheViewModel: CacheViewModel = viewModel(factory = CacheViewModel.Factory) + val qualityViewModel: StreamQualityViewModel = viewModel(factory = StreamQualityViewModel.Factory) val library by libraryViewModel.library.collectAsStateWithLifecycle() val playerState by playerViewModel.state.collectAsStateWithLifecycle() @@ -525,17 +527,25 @@ private fun WaveFlowRoot( composable(Routes.SERVER_ACCOUNT) { serverState.connected?.let { session -> - // La taille bouge à chaque piste lue : elle se - // relève à l'ouverture, pas une fois pour toutes. - LaunchedEffect(Unit) { cacheViewModel.refresh() } + val qualityState by qualityViewModel.state.collectAsStateWithLifecycle() + + // La taille bouge à chaque piste lue, et ffmpeg peut + // avoir été installé ou retiré du serveur : les deux + // se relèvent à l'ouverture, pas une fois pour toutes. + LaunchedEffect(Unit) { + cacheViewModel.refresh() + qualityViewModel.refresh() + } ServerAccountScreen( session = session, + quality = qualityState, cache = cacheState, onDisconnect = { serverViewModel.disconnect() navController.popBackStack() }, + onChooseQuality = qualityViewModel::choose, onClearCache = cacheViewModel::clear, onDismissCacheError = cacheViewModel::dismissError, bottomPadding = listBottomPadding, diff --git a/app/src/main/java/app/waveflow/data/PreferencesStore.kt b/app/src/main/java/app/waveflow/data/PreferencesStore.kt index 77814e4..09b8e5c 100644 --- a/app/src/main/java/app/waveflow/data/PreferencesStore.kt +++ b/app/src/main/java/app/waveflow/data/PreferencesStore.kt @@ -12,6 +12,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import app.waveflow.model.AppPreferences import app.waveflow.model.PlaybackSpeed +import app.waveflow.model.StreamQuality import app.waveflow.model.ThemeChoice import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -45,6 +46,14 @@ interface PreferencesStore { * notification part à la bonne vitesse. */ suspend fun setPlaybackSpeed(speed: Float) + + /** + * Change la qualité de lecture des pistes du serveur. + * + * Comme la vitesse, c'est le service qui l'observe : une file lancée depuis + * Android Auto doit partir dans la qualité choisie, écran fermé. + */ + suspend fun setStreamQuality(quality: StreamQuality) } /** @@ -114,6 +123,10 @@ class DataStorePreferencesStore( override suspend fun setPlaybackSpeed(speed: Float) = ecrire("Vitesse de lecture") { it[PLAYBACK_SPEED] = PlaybackSpeed.borner(speed) } + /** Écrite par son nom, comme le thème : voir [StreamQuality] pour ce que cela interdit. */ + override suspend fun setStreamQuality(quality: StreamQuality) = + ecrire("Qualité de lecture") { it[STREAM_QUALITY] = quality.name } + /** * Écrit une préférence sans faire tomber celui qui la demande. * @@ -167,6 +180,12 @@ class DataStorePreferencesStore( playbackSpeed = (valeurs[PLAYBACK_SPEED] as? Float) ?.let(PlaybackSpeed::borner) ?: AppPreferences().playbackSpeed, + // Un profil inconnu retombe sur l'original, jamais sur un autre + // profil transcodé : ne pas savoir ce qu'on a choisi ne doit pas + // décider à la place de l'utilisateur de ce qu'il perd en fidélité. + streamQuality = (valeurs[STREAM_QUALITY] as? String) + ?.let { name -> StreamQuality.entries.firstOrNull { it.name == name } } + ?: AppPreferences().streamQuality, ) } @@ -175,6 +194,7 @@ class DataStorePreferencesStore( val THEME = stringPreferencesKey("theme") val PLAYBACK_SPEED = floatPreferencesKey("playback_speed") + val STREAM_QUALITY = stringPreferencesKey("stream_quality") /** Trois reprises : de quoi passer un incident, pas une corruption. */ const val MAX_TENTATIVES = 3L 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 a121ea5..f98f03f 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -5,6 +5,7 @@ import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail import app.waveflow.model.RemoteSearchResults +import app.waveflow.model.StreamRendering /** * Lecture du catalogue d'un serveur WaveFlow. @@ -57,8 +58,25 @@ interface CatalogApi { * 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. + * + * Le ticket ne porte pas le rendu : [rendering] s'ajoute à l'URL rendue, et + * le serveur le lit au moment de servir. L'original n'y ajoute rien — le + * serveur refuse un débit accompagnant `raw`. + */ + suspend fun streamTicket( + serverUrl: String, + accessToken: String, + trackId: String, + rendering: StreamRendering, + ): String + + /** + * `GET /api/v2/transcode/status` : le serveur sait-il transcoder ? + * + * Un serveur sans ffmpeg sert les originaux et rien d'autre. Lui demander + * une version transcodée ferait échouer chaque piste. */ - suspend fun streamTicket(serverUrl: String, accessToken: String, trackId: String): String + suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean } /** 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 ec27b35..e58a037 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -5,6 +5,7 @@ import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail import app.waveflow.model.RemoteSearchResults +import app.waveflow.model.StreamRendering /** * Le catalogue distant, muni d'une session. @@ -39,8 +40,12 @@ class CatalogRepository( * 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) } + suspend fun streamUrl(trackId: String, rendering: StreamRendering): String = + authorized { url, token -> api.streamTicket(url, token, trackId, rendering) } + + /** Voir [CatalogApi.transcodingAvailable]. */ + suspend fun transcodingAvailable(): Boolean = + authorized { url, token -> api.transcodingAvailable(url, token) } /** * 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 425a532..07db249 100644 --- a/app/src/main/java/app/waveflow/data/remote/Dto.kt +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -139,6 +139,18 @@ internal data class StreamTicketResponse( @SerialName("expires_at") val expiresAt: Long, ) +/** + * `GET /api/v2/transcode/status`. + * + * Le serveur rend aussi `active`, `global_limit` et `per_user_limit` ; seul + * `available` sert aujourd'hui, et un champ lu sans être utilisé serait un + * champ de plus à croire à jour. + */ +@Serializable +internal data class TranscodeStatusResponse( + val available: Boolean, +) + @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 4f3712a..af80c58 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -5,6 +5,7 @@ import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail import app.waveflow.model.RemoteSearchResults +import app.waveflow.model.StreamRendering import kotlinx.serialization.SerializationException /** Catalogue distant, par-dessus [ServerHttp]. */ @@ -96,6 +97,7 @@ class HttpCatalogApi( serverUrl: String, accessToken: String, trackId: String, + rendering: StreamRendering, ): String { val ticket = http.post( serverUrl = serverUrl, @@ -104,9 +106,27 @@ class HttpCatalogApi( accessToken = accessToken, ).decode() - return http.absoluteUrl(serverUrl, ticket.url) + return http.absoluteUrl(serverUrl, ticket.url, query = rendering.toQuery()) } + override suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean = + http.get( + serverUrl = serverUrl, + path = TRANSCODE_STATUS, + accessToken = accessToken, + ).decode().available + + /** L'original ne dit rien : le serveur le sert par défaut, et refuse qu'on lui donne un débit. */ + private fun StreamRendering.toQuery(): Map = + if (isOriginal) { + emptyMap() + } else { + buildMap { + put("format", format) + bitrate?.let { put("bitrate", it.toString()) } + } + } + private fun artwork(serverUrl: String) = ArtworkUrls(serverUrl, http) private fun pageQuery(offset: Int, limit: Int) = mapOf( @@ -121,6 +141,7 @@ class HttpCatalogApi( } private companion object { + const val TRANSCODE_STATUS = "api/v2/transcode/status" const val ALBUMS = "api/v2/albums" const val ARTISTS = "api/v2/artists" const val TRACKS = "api/v2/tracks" 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 22cb260..4ee2b94 100644 --- a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt +++ b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt @@ -144,14 +144,20 @@ class ServerHttp( * * @param pathSegment ajouté après [path], et encodé — un identifiant ou un * hachage venu d'une réponse n'a pas à être interpolé dans le chemin. + * @param query ajoutée encodée, pour la même raison. */ - fun absoluteUrl(serverUrl: String, path: String, pathSegment: String? = null): String { + fun absoluteUrl( + serverUrl: String, + path: String, + pathSegment: String? = null, + query: Map = emptyMap(), + ): String { if (!path.startsWith("/") || path.startsWith("//")) { throw ServerException.Unexpected("Chemin de diffusion inattendu : $path") } return serverUrl - .toApiUrl(path = path.removePrefix("/"), pathSegment = pathSegment, query = emptyMap()) + .toApiUrl(path = path.removePrefix("/"), pathSegment = pathSegment, query = query) .toString() } diff --git a/app/src/main/java/app/waveflow/model/AppPreferences.kt b/app/src/main/java/app/waveflow/model/AppPreferences.kt index 1672628..6ee251b 100644 --- a/app/src/main/java/app/waveflow/model/AppPreferences.kt +++ b/app/src/main/java/app/waveflow/model/AppPreferences.kt @@ -6,10 +6,13 @@ package app.waveflow.model * @property playbackSpeed la vitesse de lecture. Elle se persiste parce qu'elle * décrit une habitude et non un geste : qui écoute ses podcasts à ×1,5 ne * veut pas le redire à chaque lancement. Voir [PlaybackSpeed] pour ses bornes. + * @property streamQuality ce qu'on demande au serveur pour chaque piste. Elle + * ne dit rien des fichiers de l'appareil, qui ne passent pas par lui. */ data class AppPreferences( val theme: ThemeChoice = ThemeChoice.System, val playbackSpeed: Float = PlaybackSpeed.NORMALE, + val streamQuality: StreamQuality = StreamQuality.Original, ) /** diff --git a/app/src/main/java/app/waveflow/model/StreamQuality.kt b/app/src/main/java/app/waveflow/model/StreamQuality.kt new file mode 100644 index 0000000..6e06919 --- /dev/null +++ b/app/src/main/java/app/waveflow/model/StreamQuality.kt @@ -0,0 +1,62 @@ +package app.waveflow.model + +/** + * Ce qu'on demande au serveur pour une piste : le fichier tel quel, ou une + * version transcodée à un débit donné. + * + * @property format `raw`, `mp3` ou `opus` — les trois que le serveur sait servir. + * @property bitrate en kbit/s. `null` pour l'original, qui n'a pas de débit à + * choisir : le serveur refuse d'ailleurs un débit accompagnant `raw`. + */ +data class StreamRendering(val format: String, val bitrate: Int?) { + + /** Le fichier tel qu'il est sur le serveur, sans transcodage. */ + val isOriginal: Boolean get() = format == FORMAT_ORIGINAL + + companion object { + /** Le défaut du serveur : aucun transcodage. */ + const val FORMAT_ORIGINAL = "raw" + + val ORIGINAL = StreamRendering(FORMAT_ORIGINAL, bitrate = null) + } +} + +/** + * La qualité de lecture des pistes du serveur. + * + * Des profils et non un codec et un débit à régler : on sait ce qu'on veut + * épargner — un forfait, une liaison lente —, rarement ce que valent 96 kbit/s + * d'Opus face à 192 de MP3. Le résumé de chaque profil parle donc de ce qu'il + * coûte, pas de la manière dont il est obtenu. + * + * Opus pour les deux profils transcodés : à débit égal, c'est le plus fidèle des + * deux formats que le serveur sait produire, et Android le décode de lui-même + * sur toutes les versions que l'application accepte. + * + * [Original] est le défaut. Ne toucher à rien ne doit rien changer à ce qu'on + * entendait avant que le réglage n'existe. + * + * **Le nom de chaque entrée est ce qui s'écrit sur le disque.** En renommer une + * ferait retomber sur le défaut quiconque l'avait choisie. + */ +enum class StreamQuality( + val label: String, + val summary: String, + val rendering: StreamRendering, +) { + Original( + label = "Qualité d'origine", + summary = "Le fichier tel qu'il est sur le serveur. Le plus fidèle, et le plus lourd.", + rendering = StreamRendering.ORIGINAL, + ), + Haute( + label = "Haute qualité", + summary = "Environ 70 Mo par heure d'écoute, très proche de l'original.", + rendering = StreamRendering(format = "opus", bitrate = 160), + ), + Economie( + label = "Économie", + summary = "Environ 45 Mo par heure d'écoute, pour un forfait serré.", + rendering = StreamRendering(format = "opus", bitrate = 96), + ), +} diff --git a/app/src/main/java/app/waveflow/playback/BrowseCallback.kt b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt index ac90bd2..421f6a8 100644 --- a/app/src/main/java/app/waveflow/playback/BrowseCallback.kt +++ b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt @@ -6,9 +6,11 @@ import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession import androidx.media3.session.SessionError +import app.waveflow.model.StreamRendering import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors import java.util.concurrent.ConcurrentHashMap /** @@ -17,11 +19,18 @@ import java.util.concurrent.ConcurrentHashMap * Séparé du service pour être éprouvable sans en démarrer un : tout ce qui suit * est de la traduction, et une traduction se vérifie. * - * Toutes les réponses sont immédiates. L'arbre lit un instantané déjà en + * Les réponses de navigation sont immédiates. L'arbre lit un instantané déjà en * mémoire ; rien ici ne part sur le réseau ni sur le disque, et un hôte comme - * Android Auto n'attend pas. + * Android Auto n'attend pas. Seul l'ajout à la file peut attendre, et seulement + * au démarrage à froid — voir [addMediaItems]. + * + * @param rendering le rendu à demander au serveur pour les pistes distantes, + * dès qu'il est connu. */ -class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callback { +class BrowseCallback( + private val tree: BrowseTree, + private val rendering: () -> ListenableFuture, +) : MediaLibrarySession.Callback { /** * Les nœuds qu'un navigateur a demandé à suivre, au moins une fois. @@ -123,12 +132,35 @@ class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callbac mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList, - ): ListenableFuture> { + ): ListenableFuture> = addMediaItems(mediaItems) + + /** + * Ce que [onAddMediaItems] répond, sans la session qu'il ne consulte pas. + * + * Les pistes du serveur y reçoivent le rendu choisi. C'est ici, dans le + * service, et non dans l'application qui bâtit la file : une file arrive + * aussi d'Android Auto ou d'une reprise de lecture, sans écran ouvert. + * + * Le rendu est lu **une fois pour toute la file**. Une file ne doit pas se + * partager entre deux qualités parce que le réglage a changé pendant qu'on + * la traduisait. + * + * La réponse attend ce rendu s'il n'est pas encore connu — quelques + * millisecondes au démarrage à froid, le temps de lire le fichier de + * préférences. Poser le défaut à sa place ferait partir en qualité + * d'origine une écoute réglée en Économie, précisément sur le forfait + * qu'on voulait ménager. + */ + internal fun addMediaItems(mediaItems: List): ListenableFuture> { val resolved = mediaItems.flatMap { item -> if (item.localConfiguration != null) listOf(item) else tree.resolve(item.mediaId) } - return Futures.immediateFuture(resolved.toMutableList()) + return Futures.transform( + rendering(), + { choisi -> resolved.map { it.withRendering(choisi) }.toMutableList() }, + MoreExecutors.directExecutor(), + ) } } diff --git a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt index a549c5c..79fadba 100644 --- a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt +++ b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt @@ -5,6 +5,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import app.waveflow.model.RemoteSong import app.waveflow.model.Song +import app.waveflow.model.StreamRendering /** * Traduction des morceaux vers [MediaItem], et retour. @@ -101,9 +102,7 @@ internal fun localSongIdOf(mediaId: String): Long? = * 64 kbit/s à qui demande l'original. C'est d'ailleurs ainsi que le serveur * nomme ses propres entrées de cache. * - * Le client ne demande aujourd'hui que [DEFAULT_FORMAT], sans débit — le - * transcodage n'est pas branché. Mettre les deux dès maintenant évite qu'un - * ajout futur du format oublie le débit, et fasse se recouvrir deux versions. + * Les deux sont posés ensemble par [withRendering], jamais séparément. * * @param bitrate omis de la clé quand il ne décrit aucun rendu — absent, nul ou * négatif — pour que le cas courant reste lisible : `remote::raw`. Aucun @@ -121,7 +120,47 @@ internal fun cacheKeyOf( } /** Le défaut du serveur : le fichier tel quel, sans transcodage. */ -internal const val DEFAULT_FORMAT = "raw" +internal const val DEFAULT_FORMAT = StreamRendering.FORMAT_ORIGINAL + +/** + * Pose sur une piste distante le rendu à demander au serveur. + * + * L'URI **et** la clé de cache sont réécrites ensemble, depuis le même rendu. + * Le cache calcule sa clé avant que [RemoteStreamResolver] ne construise l'URL : + * deux lectures du réglage, une à chaque bout, laisseraient un changement + * s'intercaler entre elles. Une version Opus se rangerait alors sous le nom de + * l'original, et serait resservie à qui demande l'original. + * + * L'original laisse le marqueur nu, et retrouve ainsi la clé qu'il portait avant + * que la qualité ne se choisisse : le cache déjà constitué reste valable. + * + * Une piste locale est rendue telle quelle : elle ne passe pas par le serveur. + */ +internal fun MediaItem.withRendering(rendering: StreamRendering): MediaItem { + val uri = localConfiguration?.uri ?: return this + val trackId = trackIdOfRemoteUri(uri) ?: return this + + val marqueur = uri.buildUpon().clearQuery().apply { + if (!rendering.isOriginal) { + appendQueryParameter(PARAM_FORMAT, rendering.format) + rendering.bitrate?.let { appendQueryParameter(PARAM_BITRATE, it.toString()) } + } + }.build() + + return buildUpon() + .setUri(marqueur) + .setCustomCacheKey(cacheKeyOf(trackId, rendering.format, rendering.bitrate)) + .build() +} + +/** Le rendu que porte le marqueur d'une piste distante ; l'original s'il n'en dit rien. */ +internal fun renderingOfRemoteUri(uri: android.net.Uri): StreamRendering { + val format = uri.getQueryParameter(PARAM_FORMAT) ?: return StreamRendering.ORIGINAL + return StreamRendering(format, uri.getQueryParameter(PARAM_BITRATE)?.toIntOrNull()) +} + +private const val PARAM_FORMAT = "format" +private const val PARAM_BITRATE = "bitrate" /** Identifiant de piste serveur, ou `null` si la piste est locale. */ internal fun trackIdOfRemoteUri(uri: android.net.Uri): String? = diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index bce7f6c..9ca8e11 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -14,7 +14,12 @@ import androidx.media3.session.MediaSession import app.waveflow.WaveFlowApp import app.waveflow.data.PlayHistoryRepository import app.waveflow.data.PreferencesStore +import app.waveflow.model.StreamQuality +import app.waveflow.model.StreamRendering import coil.imageLoader +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -22,6 +27,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -68,7 +75,15 @@ class PlaybackService : MediaLibraryService() { * parce qu'il faut pouvoir lui redemander de prévenir ses abonnés à chaque * fois que [browseSnapshot] change. */ - private val browseCallback = BrowseCallback(BrowseTree { browseSnapshot }) + private val browseCallback = BrowseCallback(BrowseTree { browseSnapshot }, ::renduDemande) + + /** + * La qualité de lecture choisie, `null` tant que le réglage n'a pas été lu. + * + * `null` et non le défaut : le défaut est une valeur qu'on pourrait poser + * par erreur sur une file. Voir [renduDemande]. + */ + private val qualite = MutableStateFlow(null) override fun onCreate() { super.onCreate() @@ -111,6 +126,7 @@ class PlaybackService : MediaLibraryService() { player.addListener(historyListener(container.playHistoryRepository)) observeSleepTimer(container.sleepTimer, player) observePlaybackSpeed(container.preferencesStore, player) + observeStreamQuality(container.preferencesStore) observeAbLoop(container.abLoop, player) // Après la session, et pas avant : la première valeur du flux arrive @@ -118,6 +134,43 @@ class PlaybackService : MediaLibraryService() { observeLibrary(container) } + /** + * Tient [qualite] à jour tant que le service vit. + * + * Observée plutôt que lue à la demande, pour la même raison que la vitesse : + * le service joue aussi quand aucun écran n'est ouvert. + */ + private fun observeStreamQuality(preferences: PreferencesStore) { + artworkScope.launch { + preferences.preferences + .map { it.streamQuality } + .distinctUntilChanged() + .collect { qualite.value = it } + } + } + + /** + * Le rendu à poser sur une file, dès que le réglage est connu. + * + * Immédiat en régime établi. Au démarrage à froid — le service lancé par + * Android Auto ou une reprise de lecture —, la file peut arriver avant la + * première lecture du fichier de préférences : on attend alors la valeur. + * Elle arrive toujours, le magasin rendant ses défauts plutôt que + * d'échouer. + * + * La lecture de [qualite] qui précède n'est qu'un raccourci, et ne crée pas + * d'écart : si la valeur arrive entre elle et l'abonnement, `first` sur un + * `StateFlow` la rend aussitôt. Rien ne peut donc être manqué, et la file + * n'attend jamais une émission déjà passée. + */ + private fun renduDemande(): ListenableFuture { + qualite.value?.let { return Futures.immediateFuture(it.rendering) } + + val futur = SettableFuture.create() + artworkScope.launch { futur.set(qualite.filterNotNull().first().rendering) } + return futur + } + /** * Met la lecture en pause quand la minuterie de veille arrive à échéance. * diff --git a/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt b/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt index 0d3f3e5..3721fb9 100644 --- a/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt +++ b/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt @@ -19,6 +19,11 @@ import java.io.IOException * * 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. + * + * Le rendu demandé est lu **sur le marqueur**, et non dans les préférences : la + * clé de cache a été calculée depuis ce même marqueur, avant ce résolveur. Relire + * le réglage ici laisserait un changement s'intercaler entre les deux, et une + * version se rangerait sous le nom d'une autre. Voir [withRendering]. */ class RemoteStreamResolver( private val catalogRepository: CatalogRepository, @@ -26,9 +31,10 @@ class RemoteStreamResolver( override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { val trackId = trackIdOfRemoteUri(dataSpec.uri) ?: return dataSpec + val rendering = renderingOfRemoteUri(dataSpec.uri) val url = try { - runBlocking { catalogRepository.streamUrl(trackId) } + runBlocking { catalogRepository.streamUrl(trackId, rendering) } } 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 diff --git a/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt b/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt new file mode 100644 index 0000000..efe9096 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt @@ -0,0 +1,133 @@ +package app.waveflow.ui.quality + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import app.waveflow.model.StreamQuality + +/** + * La qualité de lecture, choisie parmi des profils. + * + * Sous le compte serveur, à côté du cache : elle ne concerne que les pistes du + * serveur, et décide de ce que le cache contiendra. + * + * La ligne entière porte le geste et le bouton radio n'est qu'un témoin, comme + * pour le thème : c'est ce qui fait annoncer « 2 sur 3 » par le lecteur d'écran, + * et donne à la cible toute la largeur. + */ +@Composable +fun StreamQualitySection( + state: StreamQualityUiState, + onChoose: (StreamQuality) -> Unit, + modifier: Modifier = Modifier, +) { + Card(modifier = modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) { + Text( + text = "Qualité de lecture", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Column(Modifier.selectableGroup()) { + StreamQuality.entries.forEach { quality -> + QualityRow( + quality = quality, + selected = quality == state.current, + enabled = state.isSelectable(quality), + onChoose = onChoose, + ) + } + } + + Text( + text = note(state), + style = MaterialTheme.typography.bodySmall, + color = if (state.isCurrentUnavailable) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} + +@Composable +private fun QualityRow( + quality: StreamQuality, + selected: Boolean, + enabled: Boolean, + onChoose: (StreamQuality) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = { onChoose(quality) }, + ) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + RadioButton(selected = selected, onClick = null, enabled = enabled) + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(start = 16.dp), + ) { + Text( + text = quality.label, + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + text = quality.summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Ce qu'il faut savoir sous les choix. + * + * Par défaut, que le réglage ne touche pas la file en cours : le rendu est posé + * sur chaque piste quand elle y entre, et changer d'avis en pleine écoute sans + * que rien ne bouge se lirait sinon comme une panne. + */ +internal fun note(state: StreamQualityUiState): String = when { + state.isCurrentUnavailable -> + "Ce serveur ne sait pas transcoder : ses pistes ne pourront pas être lues " + + "dans la qualité choisie. Revenez à la qualité d'origine." + + state.transcodingAvailable == false -> + "Ce serveur ne sait pas transcoder : seule la qualité d'origine est disponible." + + else -> "S'applique aux pistes lancées ensuite, pas à la file en cours." +} diff --git a/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt b/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt new file mode 100644 index 0000000..7905421 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt @@ -0,0 +1,103 @@ +package app.waveflow.ui.quality + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import app.waveflow.WaveFlowApp +import app.waveflow.data.PreferencesStore +import app.waveflow.data.remote.CatalogRepository +import app.waveflow.data.remote.ServerException +import app.waveflow.model.StreamQuality +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Ce que l'écran sait de la qualité de lecture. + * + * @property transcodingAvailable ce que le serveur a dit de sa capacité à + * transcoder, `null` tant qu'il n'a rien dit. L'inconnu ne ferme aucun choix : + * seul un refus explicite le fait, sans quoi une coupure réseau passagère + * grisait des profils que le serveur sert très bien. + */ +data class StreamQualityUiState( + val current: StreamQuality = StreamQuality.Original, + val transcodingAvailable: Boolean? = null, +) { + /** L'original se sert toujours ; le reste suppose un serveur qui transcode. */ + fun isSelectable(quality: StreamQuality): Boolean = + quality.rendering.isOriginal || transcodingAvailable != false + + /** + * Le choix enregistré ne peut pas être servi par ce serveur. + * + * Le cas se présente : la préférence appartient à l'application, pas au + * serveur, et elle survit à un changement de serveur. + */ + val isCurrentUnavailable: Boolean get() = !isSelectable(current) +} + +/** La qualité de lecture, vue et choisie depuis le compte serveur. */ +class StreamQualityViewModel( + private val store: PreferencesStore, + private val catalog: CatalogRepository, +) : ViewModel() { + + private val disponible = MutableStateFlow(null) + + /** + * `Eagerly`, comme les réglages : l'écran doit s'ouvrir sur le choix + * enregistré, pas sur le défaut qu'il remplacerait sous les yeux. + */ + val state: StateFlow = combine(store.preferences, disponible) { prefs, dispo -> + StreamQualityUiState(current = prefs.streamQuality, transcodingAvailable = dispo) + }.stateIn(viewModelScope, SharingStarted.Eagerly, StreamQualityUiState()) + + private var releve: Job? = null + + /** + * À l'ouverture de l'écran : ffmpeg peut avoir été installé, ou retiré, + * depuis la dernière fois. + * + * Un relevé en échec laisse ce qu'on savait déjà. Un serveur injoignable + * n'a rien appris de neuf sur sa capacité à transcoder. + */ + fun refresh() { + releve?.cancel() + releve = viewModelScope.launch { + try { + disponible.value = catalog.transcodingAvailable() + } catch (error: ServerException) { + Log.w(TAG, "Capacité de transcodage inconnue", error) + } + } + } + + /** Un profil que le serveur a déclaré ne pas servir n'est pas enregistré. */ + fun choose(quality: StreamQuality) { + if (!state.value.isSelectable(quality)) return + viewModelScope.launch { store.setStreamQuality(quality) } + } + + companion object { + private const val TAG = "StreamQualityViewModel" + + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + as WaveFlowApp + StreamQualityViewModel( + store = app.container.preferencesStore, + catalog = app.container.catalogRepository, + ) + } + } + } +} diff --git a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt index f4d461d..04c6895 100644 --- a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt @@ -45,8 +45,11 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.waveflow.model.ServerSession +import app.waveflow.model.StreamQuality import app.waveflow.ui.cache.CacheSection import app.waveflow.ui.cache.CacheUiState +import app.waveflow.ui.quality.StreamQualitySection +import app.waveflow.ui.quality.StreamQualityUiState /** * Ouverture d'une session serveur. @@ -70,16 +73,20 @@ fun ServerSignInScreen( } /** - * Le compte connecté, la sortie, et le cache des pistes qu'il a servies. + * Le compte connecté, la sortie, la qualité de lecture et le cache des pistes + * qu'il a servies. * - * Le cache trouve sa place ici plutôt que dans des réglages généraux, qui - * n'existent pas : il ne contient que des pistes du serveur. + * La qualité et le cache trouvent leur place ici plutôt que dans les réglages + * généraux : ils ne concernent que les pistes du serveur. La qualité vient avant + * le cache, parce qu'elle décide de ce qu'il contiendra. */ @Composable fun ServerAccountScreen( session: ServerSession.Connected, + quality: StreamQualityUiState, cache: CacheUiState, onDisconnect: () -> Unit, + onChooseQuality: (StreamQuality) -> Unit, onClearCache: () -> Unit, onDismissCacheError: () -> Unit, modifier: Modifier = Modifier, @@ -90,6 +97,10 @@ fun ServerAccountScreen( Spacer(Modifier.height(24.dp)) + StreamQualitySection(state = quality, onChoose = onChooseQuality) + + Spacer(Modifier.height(16.dp)) + CacheSection( state = cache, onClear = onClearCache, diff --git a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt index 3a488fc..95a168f 100644 --- a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt +++ b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.preferencesOf import androidx.datastore.preferences.core.stringPreferencesKey import app.waveflow.model.PlaybackSpeed +import app.waveflow.model.StreamQuality import app.waveflow.model.ThemeChoice import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -280,4 +281,46 @@ class PreferencesStoreTest { assertEquals(listOf(ThemeChoice.Light, ThemeChoice.Dark), vus) } + + @Test + fun `sans rien de choisi la lecture est en qualite d'origine`() = runTest { + // Ne toucher à rien ne doit rien changer à ce qu'on entendait avant que + // le réglage n'existe. + val qualite = avecUnMagasin { it.preferences.first().streamQuality } + + assertEquals(StreamQuality.Original, qualite) + } + + @Test + fun `la qualite de lecture survit a la relecture`() = runTest { + avecUnMagasin { it.setStreamQuality(StreamQuality.Economie) } + + val qualite = avecUnMagasin { it.preferences.first().streamQuality } + + assertEquals(StreamQuality.Economie, qualite) + } + + @Test + fun `un profil inconnu du fichier retombe sur l'original`() = runTest { + // Un profil qu'une version future aurait ajouté, puis un retour en + // arrière. Retomber sur un autre profil transcodé déciderait à la place + // de l'utilisateur de ce qu'il perd en fidélité. + val magasin = magasinFige(preferencesOf(stringPreferencesKey("stream_quality") to "Automatique")) + + assertEquals(StreamQuality.Original, magasin.preferences.first().streamQuality) + } + + @Test + fun `une qualite d'un autre type que le sien ne fait pas tomber la collecte`() = runTest { + val magasin = magasinFige(preferencesOf(floatPreferencesKey("stream_quality") to 1f)) + + assertEquals(StreamQuality.Original, magasin.preferences.first().streamQuality) + } + + @Test + fun `une qualite qui ne s'ecrit pas ne fait pas tomber l'appelant`() = runTest { + // Un test à part, pour la raison déjà dite plus haut : une assertion + // « rien n'est levé » ne supporte qu'un seul appel. + magasinIncapableDEcrire().setStreamQuality(StreamQuality.Economie) + } } 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 8a52148..26da9ed 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -1,5 +1,6 @@ package app.waveflow.data.remote +import app.waveflow.model.StreamRendering import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -164,7 +165,7 @@ class HttpCatalogApiTest { 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 streamUrl = api.streamTicket(url(), "wfa_1", "c07f8d98", StreamRendering.ORIGINAL) val request = server.takeRequest() assertEquals("POST", request.method) @@ -181,7 +182,7 @@ class HttpCatalogApiTest { // n'atteindrait plus le serveur. server.enqueue(MockResponse().setBody(TICKET_BODY)) - val streamUrl = api.streamTicket("${url()}/musique", "wfa_1", "c07f8d98") + val streamUrl = api.streamTicket("${url()}/musique", "wfa_1", "c07f8d98", StreamRendering.ORIGINAL) assertEquals("${url()}/musique/api/v2/stream/VkdLrczM", streamUrl) } @@ -195,7 +196,7 @@ class HttpCatalogApiTest { MockResponse().setBody("""{"url":"//ailleurs.test/api/v2/stream/x","expires_at":0}"""), ) - val error = echecDe { api.streamTicket(url(), "wfa_1", "c07f8d98") } + val error = echecDe { api.streamTicket(url(), "wfa_1", "c07f8d98", StreamRendering.ORIGINAL) } assertTrue(error.toString(), error is ServerException.Unexpected) } @@ -210,11 +211,36 @@ class HttpCatalogApiTest { ), ) - val error = echecDe { api.streamTicket(url(), "wfa_1", "c07f8d98") } + val error = echecDe { api.streamTicket(url(), "wfa_1", "c07f8d98", StreamRendering.ORIGINAL) } assertTrue(error.toString(), error is ServerException.Unexpected) } + @Test + fun `un rendu transcode s'ajoute a l'URL de diffusion`() = runTest { + // Le ticket ne porte pas le rendu : c'est l'URL rendue qui le dit au + // serveur, au moment où le lecteur l'ouvre. + server.enqueue(MockResponse().setBody(TICKET_BODY)) + + val streamUrl = api.streamTicket(url(), "wfa_1", "c07f8d98", StreamRendering("opus", 96)) + + assertEquals("${url()}/api/v2/stream/VkdLrczM?format=opus&bitrate=96", streamUrl) + } + + @Test + fun `un serveur sans ffmpeg le dit`() = runTest { + server.enqueue( + MockResponse().setBody("""{"available":false,"active":0,"global_limit":4,"per_user_limit":2}"""), + ) + + val disponible = api.transcodingAvailable(url(), "wfa_1") + + val request = server.takeRequest() + assertEquals("/api/v2/transcode/status", request.path) + assertEquals("Bearer wfa_1", request.getHeader("Authorization")) + assertEquals(false, disponible) + } + @Test fun `un jeton refuse remonte comme tel`() = runTest { server.enqueue( diff --git a/app/src/test/java/app/waveflow/playback/BrowseCallbackTest.kt b/app/src/test/java/app/waveflow/playback/BrowseCallbackTest.kt new file mode 100644 index 0000000..7329339 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/BrowseCallbackTest.kt @@ -0,0 +1,91 @@ +package app.waveflow.playback + +import app.waveflow.model.StreamQuality +import app.waveflow.model.StreamRendering +import app.waveflow.testing.remoteSong +import app.waveflow.testing.song +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Ce que devient une file en entrant dans le service. + * + * Par [BrowseCallback.addMediaItems] plutôt que par `onAddMediaItems` : la + * session et l'appelant n'y sont pas consultés, et les fabriquer n'éprouverait + * que leur fabrication. `PlaybackServiceQualityTest` prouve de son côté qu'une + * file envoyée par un vrai contrôleur passe bien par là. + * + * Robolectric pour disposer d'un vrai `android.net.Uri`. + */ +@RunWith(RobolectricTestRunner::class) +class BrowseCallbackTest { + + private fun rappel(rendu: () -> ListenableFuture) = + BrowseCallback(BrowseTree { BrowseSnapshot() }, rendu) + + private fun immediat(qualite: StreamQuality): () -> ListenableFuture = + { Futures.immediateFuture(qualite.rendering) } + + @Test + fun `une piste du serveur entre dans la file dans la qualite choisie`() { + val file = rappel(immediat(StreamQuality.Economie)) + .addMediaItems(listOf(remoteSong(id = "c07f8d98").toMediaItem())) + .get() + + val config = file.single().localConfiguration!! + assertEquals(StreamQuality.Economie.rendering, renderingOfRemoteUri(config.uri)) + assertEquals(cacheKeyOf("c07f8d98", "opus", 96), config.customCacheKey) + } + + @Test + fun `une piste de l'appareil traverse sans recevoir de rendu`() { + val locale = song(id = 42L).toMediaItem() + + val file = rappel(immediat(StreamQuality.Economie)).addMediaItems(listOf(locale)).get() + + assertEquals(listOf(locale), file) + } + + @Test + fun `tant que le reglage n'est pas lu, la file l'attend`() { + // Le démarrage à froid : Android Auto relance la lecture avant que le + // fichier de préférences n'ait été lu. Poser le défaut à ce moment-là + // ferait partir en qualité d'origine une écoute réglée en Économie. + val reglage = SettableFuture.create() + + val file = rappel { reglage }.addMediaItems(listOf(remoteSong(id = "a").toMediaItem())) + + assertFalse("la file ne doit pas partir sans le réglage", file.isDone) + + reglage.set(StreamQuality.Economie.rendering) + + assertTrue(file.isDone) + assertEquals( + StreamQuality.Economie.rendering, + renderingOfRemoteUri(file.get().single().localConfiguration!!.uri), + ) + } + + @Test + fun `une file entiere se traduit dans une seule qualite`() { + // Le réglage est lu une fois pour la file, et non une fois par piste : + // un changement pendant la traduction partagerait sinon un album entre + // deux qualités. + var lectures = 0 + val rappel = rappel { + lectures++ + Futures.immediateFuture(StreamQuality.Haute.rendering) + } + + rappel.addMediaItems(listOf("a", "b", "c").map { remoteSong(id = it).toMediaItem() }).get() + + assertEquals("une seule lecture du réglage pour trois pistes", 1, lectures) + } +} diff --git a/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt b/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt index bd56aa4..993e16c 100644 --- a/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt +++ b/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt @@ -1,6 +1,8 @@ package app.waveflow.playback import androidx.core.net.toUri +import app.waveflow.model.StreamQuality +import app.waveflow.model.StreamRendering import app.waveflow.testing.remoteSong import app.waveflow.testing.song import org.junit.Assert.assertEquals @@ -86,6 +88,56 @@ class MediaItemMapperTest { assertNotEquals(sansDebit, cacheKeyOf("piste", "raw", bitrate = 128)) } + @Test + fun `l'original garde le marqueur et la cle d'avant le reglage`() { + // Le cache constitué avant que la qualité ne se choisisse est rangé + // sous le marqueur nu : le retrouver à l'identique est ce qui le garde + // valable pour qui ne touche pas au réglage. + val avant = remoteSong(id = "c07f8d98").toMediaItem() + val apres = avant.withRendering(StreamRendering.ORIGINAL) + + assertEquals(avant.localConfiguration?.uri, apres.localConfiguration?.uri) + assertEquals(avant.localConfiguration?.customCacheKey, apres.localConfiguration?.customCacheKey) + } + + @Test + fun `le rendu pose se relit sur le marqueur, et la cle dit le meme`() { + // Les deux bouts de la chaîne : le cache nomme l'entrée d'après la clé, + // le résolveur demande au serveur d'après le marqueur. S'ils ne disent + // pas le même rendu, une version se range sous le nom d'une autre. + StreamQuality.entries.forEach { qualite -> + val rendu = qualite.rendering + val item = remoteSong(id = "c07f8d98").toMediaItem().withRendering(rendu) + val config = item.localConfiguration!! + + assertEquals(qualite.name, rendu, renderingOfRemoteUri(config.uri)) + assertEquals(qualite.name, cacheKeyOf("c07f8d98", rendu.format, rendu.bitrate), config.customCacheKey) + assertEquals(qualite.name, "c07f8d98", trackIdOfRemoteUri(config.uri)) + } + } + + @Test + fun `reposer un rendu remplace le precedent`() { + // Une piste remise en file après un changement de réglage : le marqueur + // ne doit pas cumuler deux formats, dont le résolveur ne lirait que le + // premier pendant que la clé dirait le second. + val item = remoteSong(id = "a").toMediaItem() + .withRendering(StreamRendering("opus", 96)) + .withRendering(StreamRendering("opus", 160)) + + assertEquals(StreamRendering("opus", 160), renderingOfRemoteUri(item.localConfiguration!!.uri)) + assertEquals(cacheKeyOf("a", "opus", 160), item.localConfiguration?.customCacheKey) + } + + @Test + fun `une piste locale ne recoit aucun rendu`() { + // Elle ne passe pas par le serveur : un format sur son URI la rendrait + // illisible, `content://` ne sachant qu'en faire. + val avant = song(id = 42L).toMediaItem() + + assertEquals(avant, avant.withRendering(StreamRendering("opus", 96))) + } + @Test fun `les metadonnees d'une piste distante suivent jusqu'au lecteur`() { val track = remoteSong( diff --git a/app/src/test/java/app/waveflow/playback/PlaybackServiceQualityTest.kt b/app/src/test/java/app/waveflow/playback/PlaybackServiceQualityTest.kt new file mode 100644 index 0000000..7da3dd6 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/PlaybackServiceQualityTest.kt @@ -0,0 +1,158 @@ +package app.waveflow.playback + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionToken +import androidx.test.core.app.ApplicationProvider +import app.waveflow.WaveFlowApp +import app.waveflow.data.PreferencesStore +import app.waveflow.model.StreamQuality +import app.waveflow.testing.remoteSong +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ServiceController +import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.TimeUnit + +/** + * La qualité de lecture, du fichier de préférences jusqu'à la file du lecteur. + * + * Le maillon que rien d'autre ne couvre. `PreferencesStoreTest` prouve que le + * choix survit, `BrowseCallbackTest` que le rappel pose le rendu qu'on lui + * donne ; il reste à montrer que le service **les relie**, et qu'une file + * envoyée par un vrai `MediaController` passe bien par ce rappel. + * + * Le vrai magasin de l'application, remis à l'origine après chaque test : le + * délégué `preferencesDataStore` mémorise son instance pour toute la machine + * virtuelle, et ce qu'on laisse derrière soi, un autre test le relira. + */ +@RunWith(RobolectricTestRunner::class) +class PlaybackServiceQualityTest { + + private val app: Application = ApplicationProvider.getApplicationContext() + private val preferences: PreferencesStore get() = (app as WaveFlowApp).container.preferencesStore + + private var service: ServiceController? = null + private var controleur: MediaController? = null + + @After + fun tearDown() { + controleur?.release() + service?.destroy() + runBlocking { preferences.setStreamQuality(StreamQuality.Original) } + } + + @Test + fun `une piste du serveur entre dans la file dans la qualite choisie`() { + runBlocking { preferences.setStreamQuality(StreamQuality.Economie) } + val lecteur = lecteurDuService() + + controleurConnecte().setMediaItems(listOf(remoteSong(id = "c07f8d98").toMediaItem())) + attendre("la piste dans la file du service") { lecteur.mediaItemCount == 1 } + + val config = lecteur.getMediaItemAt(0).localConfiguration!! + val rendu = StreamQuality.Economie.rendering + assertEquals(rendu, renderingOfRemoteUri(config.uri)) + assertEquals(cacheKeyOf("c07f8d98", rendu.format, rendu.bitrate), config.customCacheKey) + } + + /** Le service tel qu'Android le crée, et le lecteur qu'il tient. */ + private fun lecteurDuService(): Player { + val demarre = Robolectric.buildService(PlaybackService::class.java).create() + service = demarre + + // Robolectric ne démarre pas de vrai service sur `bindService` : on lui + // donne le `Binder` que le service rend lui-même, pour les deux actions + // que le manifeste déclare. + val composant = ComponentName(app, PlaybackService::class.java) + listOf(MediaLibraryService.SERVICE_INTERFACE, MediaSessionService.SERVICE_INTERFACE) + .forEach { action -> + val intent = Intent(action).setComponent(composant) + shadowOf(app).setComponentNameAndServiceForBindServiceForIntent( + intent, + composant, + demarre.get().onBind(intent), + ) + } + + return demarre.get().onGetSession(appelant())!!.player + } + + /** Un contrôleur lié au service, par le même chemin que l'application. */ + private fun controleurConnecte(): MediaController { + val composant = ComponentName(app, PlaybackService::class.java) + return MediaController.Builder(app, SessionToken(app, composant)) + .buildAsync() + .attendu("la liaison au service") + .also { controleur = it } + } + + private fun appelant(): MediaSession.ControllerInfo = + MediaSession.ControllerInfo.createTestOnlyControllerInfo( + /* packageName = */ "app.waveflow", + /* pid = */ 0, + /* uid = */ 0, + /* libraryVersion = */ 0, + /* interfaceVersion = */ 0, + /* trusted = */ true, + /* connectionHints = */ Bundle.EMPTY, + /* isPackageNameVerified = */ true, + ) + + /** + * Attend un futur en écoulant les messages entre deux essais. + * + * Un `get()` bloquant depuis le fil de test figerait la boucle principale, + * dont dépend justement la réponse de la session. + */ + private fun ListenableFuture.attendu(quoi: String): T { + attendre(quoi) { isDone } + return get() + } + + /** + * Bornée : une file qui n'arriverait plus doit faire échouer le test, pas + * le faire attendre indéfiniment. + */ + private fun attendre(quoi: String, condition: () -> Boolean) { + val echeance = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_S) + while (true) { + ecouler() + if (condition()) return + if (System.nanoTime() >= echeance) break + Thread.sleep(PAUSE_MS) + } + fail("Délai dépassé en attendant : $quoi") + } + + /** + * Écoule les messages de toutes les boucles encore vivantes : ExoPlayer + * tient sa machine à états sur un `HandlerThread` à lui, et le DataStore + * lit sur son propre dispatcher. + */ + private fun ecouler() { + ShadowLooper.getAllLoopers() + .filter { it.thread.isAlive } + .forEach { boucle -> runCatching { shadowOf(boucle).idle() } } + } + + private companion object { + const val TIMEOUT_S = 15L + const val PAUSE_MS = 5L + } +} diff --git a/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt b/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt index dec0700..50ff018 100644 --- a/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt +++ b/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt @@ -6,6 +6,7 @@ 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.model.StreamRendering import app.waveflow.testing.FakeCatalogApi import app.waveflow.testing.FakeServerApi import app.waveflow.testing.FakeSessionStore @@ -101,4 +102,24 @@ class RemoteStreamResolverTest { assertTrue(error.toString(), error is IOException) assertTrue(error?.cause is ServerException.Unreachable) } + + @Test + fun `le rendu demande au serveur est celui du marqueur`() = runTest { + // Lu sur le marqueur, et non dans les préférences : c'est de ce même + // marqueur que la clé de cache a été tirée, avant ce résolveur. + val catalog = FakeCatalogApi() + + resolver(catalog).resolveDataSpec(specOf("waveflow://track/c07f8d98?format=opus&bitrate=96")) + + assertEquals(StreamRendering("opus", 96), catalog.lastRendering) + } + + @Test + fun `un marqueur nu demande l'original`() = runTest { + val catalog = FakeCatalogApi() + + resolver(catalog).resolveDataSpec(specOf("waveflow://track/c07f8d98")) + + assertEquals(StreamRendering.ORIGINAL, catalog.lastRendering) + } } diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index a5db6f1..cf569ea 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -10,6 +10,7 @@ import app.waveflow.model.Playlist import app.waveflow.model.PlaylistEntry import app.waveflow.model.RemoteSong import app.waveflow.model.Song +import app.waveflow.model.StreamQuality import app.waveflow.model.ThemeChoice import app.waveflow.playback.PlaybackController import app.waveflow.playback.PlaybackState @@ -251,6 +252,11 @@ class FakePreferencesStore(initial: AppPreferences = AppPreferences()) : Prefere flux.update { it.copy(playbackSpeed = PlaybackSpeed.borner(speed)) } } + override suspend fun setStreamQuality(quality: StreamQuality) { + flux.update { it.copy(streamQuality = quality) } + } + val theme: ThemeChoice get() = flux.value.theme val playbackSpeed: Float get() = flux.value.playbackSpeed + val streamQuality: StreamQuality get() = flux.value.streamQuality } diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 4a634c6..757ef2d 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -12,6 +12,7 @@ import app.waveflow.model.RemoteArtistDetail import app.waveflow.model.RemoteSearchResults import app.waveflow.model.RemoteSong import app.waveflow.model.ServerSession +import app.waveflow.model.StreamRendering import kotlinx.coroutines.CompletableDeferred /** @@ -176,11 +177,25 @@ class FakeCatalogApi( serverUrl: String, accessToken: String, trackId: String, + rendering: StreamRendering, ): String { record(serverUrl, accessToken, null) + lastRendering = rendering return "$serverUrl/api/v2/stream/ticket-$trackId" } + /** Le rendu demandé au dernier ticket. */ + var lastRendering: StreamRendering? = null + private set + + /** Ce que le serveur répond sur sa capacité à transcoder. */ + var transcoding = true + + override suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean { + record(serverUrl, accessToken, null) + return transcoding + } + private suspend fun record(serverUrl: String, accessToken: String, page: Pair?) { calls++ lastServerUrl = serverUrl @@ -308,8 +323,11 @@ class PagingCatalogApi( serverUrl: String, accessToken: String, trackId: String, + rendering: StreamRendering, ): String = "$serverUrl/api/v2/stream/ticket-$trackId" + override suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean = true + private fun failIfDue(call: Int) { if (failFromCall > 0 && call >= failFromCall) { throw ServerException.Unreachable("coupure") diff --git a/app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt b/app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt new file mode 100644 index 0000000..4111888 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt @@ -0,0 +1,60 @@ +package app.waveflow.ui.quality + +import app.waveflow.model.StreamQuality +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Ce que la section propose, et ce qu'elle dit sous les choix. */ +class StreamQualitySectionTest { + + @Test + fun `sans rien savoir du serveur, aucun choix n'est ferme`() { + // Une coupure réseau n'apprend rien de la capacité du serveur : + // griser les profils sur un relevé manqué fermerait ce qu'il sert. + val etat = StreamQualityUiState(transcodingAvailable = null) + + StreamQuality.entries.forEach { assertTrue(it.name, etat.isSelectable(it)) } + } + + @Test + fun `un serveur sans ffmpeg ne laisse que l'original`() { + val etat = StreamQualityUiState(transcodingAvailable = false) + + assertTrue(etat.isSelectable(StreamQuality.Original)) + assertFalse(etat.isSelectable(StreamQuality.Haute)) + assertFalse(etat.isSelectable(StreamQuality.Economie)) + } + + @Test + fun `par defaut, la note dit que la file en cours n'est pas touchee`() { + // Changer d'avis en pleine écoute sans que rien ne bouge se lirait + // sinon comme une panne. + assertEquals( + "S'applique aux pistes lancées ensuite, pas à la file en cours.", + note(StreamQualityUiState(transcodingAvailable = true)), + ) + } + + @Test + fun `un serveur sans ffmpeg le dit, sans alarme quand le choix est servi`() { + val etat = StreamQualityUiState(current = StreamQuality.Original, transcodingAvailable = false) + + assertFalse(etat.isCurrentUnavailable) + assertEquals( + "Ce serveur ne sait pas transcoder : seule la qualité d'origine est disponible.", + note(etat), + ) + } + + @Test + fun `un choix que ce serveur ne sert pas est signale`() { + // La préférence survit à un changement de serveur : on peut arriver + // sur un serveur sans ffmpeg avec Économie déjà choisie. + val etat = StreamQualityUiState(current = StreamQuality.Economie, transcodingAvailable = false) + + assertTrue(etat.isCurrentUnavailable) + assertTrue(note(etat), note(etat).endsWith("Revenez à la qualité d'origine.")) + } +} diff --git a/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt b/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt new file mode 100644 index 0000000..b6604db --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt @@ -0,0 +1,123 @@ +package app.waveflow.ui.quality + +import app.waveflow.data.remote.CatalogRepository +import app.waveflow.data.remote.ServerException +import app.waveflow.data.remote.ServerSessionRepository +import app.waveflow.model.AppPreferences +import app.waveflow.model.ServerSession +import app.waveflow.model.StreamQuality +import app.waveflow.testing.FakeCatalogApi +import app.waveflow.testing.FakePreferencesStore +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import app.waveflow.testing.MainDispatcherRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * La qualité de lecture vue depuis le compte serveur. + * + * Robolectric parce que le ViewModel journalise un relevé en échec : sans lui, + * `android.util.Log` lève, et l'erreur se déguise en panne du code testé. + */ +@RunWith(RobolectricTestRunner::class) +class StreamQualityViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + 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 catalogue(api: FakeCatalogApi): CatalogRepository { + val sessions = ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(stored = session), + deviceName = "Pixel de test", + now = { 0L }, + ) + sessions.restore() + return CatalogRepository(api, sessions) + } + + @Test + fun `l'ecran s'ouvre sur le choix enregistre`() = runTest { + val magasin = FakePreferencesStore(AppPreferences(streamQuality = StreamQuality.Haute)) + + val vm = StreamQualityViewModel(magasin, catalogue(FakeCatalogApi())) + + assertEquals(StreamQuality.Haute, vm.state.value.current) + } + + @Test + fun `choisir un profil l'enregistre`() = runTest { + val magasin = FakePreferencesStore() + val vm = StreamQualityViewModel(magasin, catalogue(FakeCatalogApi())) + + vm.choose(StreamQuality.Economie) + + assertEquals(StreamQuality.Economie, magasin.streamQuality) + } + + @Test + fun `un serveur sans ffmpeg le fait savoir a l'ecran`() = runTest { + val api = FakeCatalogApi().apply { transcoding = false } + val vm = StreamQualityViewModel(FakePreferencesStore(), catalogue(api)) + + vm.refresh() + + assertEquals(false, vm.state.value.transcodingAvailable) + } + + @Test + fun `un profil que le serveur ne sert pas n'est pas enregistre`() = runTest { + // L'écran le grise déjà. La garde tient aussi pour l'appui qui arrive + // avant que l'écran ne se soit repeint. + val magasin = FakePreferencesStore() + val api = FakeCatalogApi().apply { transcoding = false } + val vm = StreamQualityViewModel(magasin, catalogue(api)) + vm.refresh() + + vm.choose(StreamQuality.Economie) + + assertEquals(StreamQuality.Original, magasin.streamQuality) + } + + @Test + fun `sans reponse du serveur, rien n'est ferme`() = runTest { + // Une coupure n'apprend rien de la capacité du serveur : l'inconnu + // reste l'inconnu, et ne grise aucun profil. + val api = FakeCatalogApi(failure = ServerException.Unreachable("coupure")) + val vm = StreamQualityViewModel(FakePreferencesStore(), catalogue(api)) + + vm.refresh() + + assertNull(vm.state.value.transcodingAvailable) + } + + @Test + fun `un releve en echec garde ce qu'on savait`() = runTest { + // Un serveur qui a dit ne pas transcoder, puis devient injoignable : + // la coupure n'a pas installé ffmpeg. Oublier sa réponse rouvrirait des + // profils qui feraient échouer chaque piste. + val api = FakeCatalogApi().apply { transcoding = false } + val vm = StreamQualityViewModel(FakePreferencesStore(), catalogue(api)) + vm.refresh() + + api.pendantLAppel = { throw ServerException.Unreachable("coupure") } + vm.refresh() + + assertEquals(false, vm.state.value.transcodingAvailable) + } +} diff --git a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt index ec63460..d30b5e0 100644 --- a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt @@ -11,7 +11,9 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import app.waveflow.model.ServerSession +import app.waveflow.model.StreamQuality import app.waveflow.ui.cache.CacheUiState +import app.waveflow.ui.quality.StreamQualityUiState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule @@ -52,18 +54,46 @@ class ServerScreenTest { } /** Écran de compte : atteint depuis la barre du haut, une fois connecté. */ - private fun afficherCompte(cache: CacheUiState = CacheUiState(usedBytes = 0L)) { + private fun afficherCompte( + cache: CacheUiState = CacheUiState(usedBytes = 0L), + quality: StreamQualityUiState = StreamQualityUiState(), + onChooseQuality: (StreamQuality) -> Unit = {}, + ) { compose.setContent { ServerAccountScreen( session = session, + quality = quality, cache = cache, onDisconnect = { deconnexions++ }, + onChooseQuality = onChooseQuality, onClearCache = { vidages++ }, onDismissCacheError = {}, ) } } + @Test + fun `choisir une qualite de lecture est transmis`() { + val choisies = mutableListOf() + afficherCompte(onChooseQuality = { choisies += it }) + + compose.onNodeWithText("Haute qualité").performClick() + + assertEquals(listOf(StreamQuality.Haute), choisies) + } + + @Test + fun `un serveur sans ffmpeg grise les profils transcodes`() { + // Proposer un profil que le serveur ne sait pas produire ferait + // échouer chaque piste : il reste visible, pour qu'on sache qu'il + // existe, mais ne se choisit pas. + afficherCompte(quality = StreamQualityUiState(transcodingAvailable = false)) + + compose.onNodeWithText("Qualité d'origine").assertIsEnabled() + compose.onNodeWithText("Haute qualité").assertIsNotEnabled() + compose.onNodeWithText("Économie").assertIsNotEnabled() + } + /** * Nombre de nœuds dont un texte contient [text]. * From bec12e59a90c0c46958654390c7d9e4fa815ae96 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 10:40:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(lecture):=20ne=20jamais=20laisser=20la?= =?UTF-8?q?=20file=20attendre=20un=20service=20d=C3=A9truit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Au démarrage à froid, le service attend la lecture du réglage de qualité avant de poser le rendu sur une file. Cette attente vivait dans une coroutine d'`artworkScope` qui réglait le futur rendu à la session. Si le service était détruit avant — ou sollicité après —, la coroutine était annulée sans régler le futur, et la réponse que Media3 attend de la session ne venait jamais. L'attente est extraite dans `firstValueAsFuture`, dont le futur se termine toujours : l'achèvement de la coroutine, quelle qu'en soit la cause, le règle en échec s'il ne l'a pas été. Les tests éprouvent au passage l'attente au niveau du service, ce que le test de bout en bout ne pouvait pas — le fichier de préférences y est déjà lu quand la file arrive. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- .../app/waveflow/playback/FirstValueFuture.kt | 34 +++++++++ .../app/waveflow/playback/PlaybackService.kt | 33 +++------ .../waveflow/playback/FirstValueFutureTest.kt | 74 +++++++++++++++++++ 3 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/app/waveflow/playback/FirstValueFuture.kt create mode 100644 app/src/test/java/app/waveflow/playback/FirstValueFutureTest.kt diff --git a/app/src/main/java/app/waveflow/playback/FirstValueFuture.kt b/app/src/main/java/app/waveflow/playback/FirstValueFuture.kt new file mode 100644 index 0000000..c0ee1cb --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/FirstValueFuture.kt @@ -0,0 +1,34 @@ +package app.waveflow.playback + +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * La première valeur non nulle de cet état, sous la forme d'un futur que Media3 + * sait attendre. + * + * Immédiat quand la valeur est déjà là ; sinon une coroutine de [scope] + * l'attend. La lecture de `value` qui précède n'est qu'un raccourci, sans écart : + * si la valeur arrive entre elle et l'abonnement, `first` sur un `StateFlow` la + * rend aussitôt. + * + * **Le futur se termine toujours**, y compris quand [scope] est annulé avant que + * la valeur n'arrive — un service détruit pendant son démarrage à froid, ou + * sollicité après sa destruction. Annuler la coroutine ne règle pas le futur de + * lui-même : il resterait en suspens, et avec lui la réponse que Media3 attend + * de la session. + */ +internal fun StateFlow.firstValueAsFuture(scope: CoroutineScope): ListenableFuture { + value?.let { return Futures.immediateFuture(it) } + + val futur = SettableFuture.create() + scope.launch { futur.set(filterNotNull().first()) } + .invokeOnCompletion { cause -> if (cause != null) futur.setException(cause) } + return futur +} diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index 9ca8e11..d9918d9 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -14,12 +14,9 @@ import androidx.media3.session.MediaSession import app.waveflow.WaveFlowApp import app.waveflow.data.PlayHistoryRepository import app.waveflow.data.PreferencesStore -import app.waveflow.model.StreamQuality import app.waveflow.model.StreamRendering import coil.imageLoader -import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.SettableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -27,8 +24,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -78,12 +73,12 @@ class PlaybackService : MediaLibraryService() { private val browseCallback = BrowseCallback(BrowseTree { browseSnapshot }, ::renduDemande) /** - * La qualité de lecture choisie, `null` tant que le réglage n'a pas été lu. + * Le rendu de la qualité choisie, `null` tant que le réglage n'a pas été lu. * * `null` et non le défaut : le défaut est une valeur qu'on pourrait poser * par erreur sur une file. Voir [renduDemande]. */ - private val qualite = MutableStateFlow(null) + private val rendu = MutableStateFlow(null) override fun onCreate() { super.onCreate() @@ -135,17 +130,17 @@ class PlaybackService : MediaLibraryService() { } /** - * Tient [qualite] à jour tant que le service vit. + * Tient [rendu] à jour tant que le service vit. * - * Observée plutôt que lue à la demande, pour la même raison que la vitesse : + * Observé plutôt que lu à la demande, pour la même raison que la vitesse : * le service joue aussi quand aucun écran n'est ouvert. */ private fun observeStreamQuality(preferences: PreferencesStore) { artworkScope.launch { preferences.preferences - .map { it.streamQuality } + .map { it.streamQuality.rendering } .distinctUntilChanged() - .collect { qualite.value = it } + .collect { rendu.value = it } } } @@ -156,20 +151,10 @@ class PlaybackService : MediaLibraryService() { * Android Auto ou une reprise de lecture —, la file peut arriver avant la * première lecture du fichier de préférences : on attend alors la valeur. * Elle arrive toujours, le magasin rendant ses défauts plutôt que - * d'échouer. - * - * La lecture de [qualite] qui précède n'est qu'un raccourci, et ne crée pas - * d'écart : si la valeur arrive entre elle et l'abonnement, `first` sur un - * `StateFlow` la rend aussitôt. Rien ne peut donc être manqué, et la file - * n'attend jamais une émission déjà passée. + * d'échouer ; et si le service est détruit avant, le futur se termine quand + * même. Voir [firstValueAsFuture]. */ - private fun renduDemande(): ListenableFuture { - qualite.value?.let { return Futures.immediateFuture(it.rendering) } - - val futur = SettableFuture.create() - artworkScope.launch { futur.set(qualite.filterNotNull().first().rendering) } - return futur - } + private fun renduDemande(): ListenableFuture = rendu.firstValueAsFuture(artworkScope) /** * Met la lecture en pause quand la minuterie de veille arrive à échéance. diff --git a/app/src/test/java/app/waveflow/playback/FirstValueFutureTest.kt b/app/src/test/java/app/waveflow/playback/FirstValueFutureTest.kt new file mode 100644 index 0000000..f6a657e --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/FirstValueFutureTest.kt @@ -0,0 +1,74 @@ +package app.waveflow.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * L'attente du réglage par le service, sous la forme que Media3 attend. + * + * C'est ce que `PlaybackService.renduDemande` rend à la session : ces tests + * éprouvent donc l'attente au démarrage à froid au niveau du service, là où + * `PlaybackServiceQualityTest` ne le peut pas — le fichier de préférences y est + * déjà lu quand la file arrive. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FirstValueFutureTest { + + @Test + fun `une valeur deja la est rendue sans attendre`() = runTest { + val futur = MutableStateFlow("lue").firstValueAsFuture(backgroundScope) + + assertTrue(futur.isDone) + assertEquals("lue", futur.get()) + } + + @Test + fun `une valeur arrivee ensuite termine le futur`() = runTest { + // Le démarrage à froid : la file arrive avant la lecture du réglage. + val etat = MutableStateFlow(null) + val futur = etat.firstValueAsFuture(backgroundScope) + runCurrent() + assertFalse("rien n'est encore lu", futur.isDone) + + etat.value = "lue" + runCurrent() + + assertEquals("lue", futur.get()) + } + + @Test + fun `une portee annulee pendant l'attente termine le futur`() = runTest { + // Le service détruit pendant qu'une file attend le réglage. Sans cela + // la réponse que Media3 attend de la session ne viendrait jamais. + val portee = CoroutineScope(Job() + StandardTestDispatcher(testScheduler)) + val futur = MutableStateFlow(null).firstValueAsFuture(portee) + runCurrent() + + portee.cancel() + runCurrent() + + assertTrue("le futur ne doit pas rester en suspens", futur.isDone) + } + + @Test + fun `une portee deja annulee termine aussi le futur`() = runTest { + // Le service sollicité après sa destruction : la coroutine ne démarre + // même pas, et c'est ce cas-là qu'un simple `set` à la fin manquait. + val portee = CoroutineScope(Job().apply { cancel() } + StandardTestDispatcher(testScheduler)) + + val futur = MutableStateFlow(null).firstValueAsFuture(portee) + runCurrent() + + assertTrue("le futur ne doit pas rester en suspens", futur.isDone) + } +}