diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index ecc8f8f..7bf4fbd 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -529,12 +529,10 @@ private fun WaveFlowRoot( serverState.connected?.let { session -> 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. + // La taille bouge à chaque piste lue : elle se relève + // à l'ouverture, pas une fois pour toutes. LaunchedEffect(Unit) { cacheViewModel.refresh() - qualityViewModel.refresh() } ServerAccountScreen( 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 f98f03f..2839e5a 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -69,14 +69,6 @@ interface CatalogApi { 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 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 e58a037..05e670b 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -43,10 +43,6 @@ class CatalogRepository( 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 07db249..425a532 100644 --- a/app/src/main/java/app/waveflow/data/remote/Dto.kt +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -139,18 +139,6 @@ 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 af80c58..fab4ff1 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -109,13 +109,6 @@ class HttpCatalogApi( 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) { @@ -141,7 +134,6 @@ 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/ui/quality/StreamQualitySection.kt b/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt index efe9096..0a9ed96 100644 --- a/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt +++ b/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt @@ -52,20 +52,18 @@ fun StreamQualitySection( QualityRow( quality = quality, selected = quality == state.current, - enabled = state.isSelectable(quality), onChoose = onChoose, ) } } Text( - text = note(state), + // Le rendu est posé sur chaque piste quand elle entre dans la + // file : changer d'avis en pleine écoute sans que rien ne bouge + // se lirait sinon comme une panne. + text = "S'applique aux pistes lancées ensuite, pas à la file en cours.", style = MaterialTheme.typography.bodySmall, - color = if (state.isCurrentUnavailable) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp), ) } @@ -76,7 +74,6 @@ fun StreamQualitySection( private fun QualityRow( quality: StreamQuality, selected: Boolean, - enabled: Boolean, onChoose: (StreamQuality) -> Unit, ) { Row( @@ -85,13 +82,12 @@ private fun QualityRow( .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) + RadioButton(selected = selected, onClick = null) Column( verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.padding(start = 16.dp), @@ -99,11 +95,7 @@ private fun QualityRow( Text( text = quality.label, style = MaterialTheme.typography.bodyLarge, - color = if (enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = MaterialTheme.colorScheme.onSurface, ) Text( text = quality.summary, @@ -113,21 +105,3 @@ private fun QualityRow( } } } - -/** - * 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 index 7905421..b824929 100644 --- a/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt @@ -1,6 +1,5 @@ package app.waveflow.ui.quality -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -8,95 +7,47 @@ 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.map 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. + * Tous les profils se choisissent, sans demander au serveur s'il sait + * transcoder : `waveflow-server` refuse de démarrer sans ffmpeg, et un serveur + * joignable le sait donc toujours. */ 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 + val state: StateFlow = store.preferences + .map { prefs -> StreamQualityUiState(current = prefs.streamQuality) } + .stateIn(viewModelScope, SharingStarted.Eagerly, StreamQualityUiState()) - /** - * À 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, - ) + StreamQualityViewModel(store = app.container.preferencesStore) } } } 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 26da9ed..316ef58 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -227,20 +227,6 @@ class HttpCatalogApiTest { 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/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 757ef2d..0460fae 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -188,14 +188,6 @@ class FakeCatalogApi( 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 @@ -326,8 +318,6 @@ class PagingCatalogApi( 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 deleted file mode 100644 index 4111888..0000000 --- a/app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -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 index b6604db..66156fd 100644 --- a/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt @@ -1,61 +1,25 @@ 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) +/** La qualité de lecture vue depuis le compte serveur. */ 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())) + val vm = StreamQualityViewModel(magasin) assertEquals(StreamQuality.Haute, vm.state.value.current) } @@ -63,61 +27,10 @@ class StreamQualityViewModelTest { @Test fun `choisir un profil l'enregistre`() = runTest { val magasin = FakePreferencesStore() - val vm = StreamQualityViewModel(magasin, catalogue(FakeCatalogApi())) + val vm = StreamQualityViewModel(magasin) 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 d30b5e0..0471efa 100644 --- a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt @@ -82,18 +82,6 @@ class ServerScreenTest { 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]. *