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/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/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..d9918d9 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -14,7 +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.StreamRendering import coil.imageLoader +import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -68,7 +70,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) + + /** + * 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 rendu = MutableStateFlow(null) override fun onCreate() { super.onCreate() @@ -111,6 +121,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 +129,33 @@ class PlaybackService : MediaLibraryService() { observeLibrary(container) } + /** + * Tient [rendu] à jour tant que le service vit. + * + * 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.rendering } + .distinctUntilChanged() + .collect { rendu.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 ; et si le service est détruit avant, le futur se termine quand + * même. Voir [firstValueAsFuture]. + */ + 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/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/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) + } +} 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]. *